drop journal framing - #1
Merged
Merged
Conversation
4 tasks
Flotapponnier
added a commit
that referenced
this pull request
May 21, 2026
Pre-registered methodology v1.1 + launch plan (5 services x us-east x 1h cadence, ~$159/mo). Required by Rule #1 to be merged before any sponsor contract. - methodology/solana-tx-landing-active.md: probe payload, submission flow, drop reasons, sponsor-proof guarantees, versioning - solana-landing-tiered-architecture.md: V0-Lean spec + escalation tiers (V0.5, V1, V2) - solana-landing-cost-model.md: probe cost formula + tip-floor analysis (note flagging arithmetic error in scenarios table) - solana-landing-deployment-checklist.md: 8-step Florent runbook (keypair, API keys, Railway env, sponsor outreach) - solana-landing-active-bench-plan.md: redirect to current docs
This was referenced May 21, 2026
Flotapponnier
added a commit
that referenced
this pull request
May 22, 2026
…xes (#83) YAML rewrites (GSC-driven): - l1-finality: title leads with Ethereum 12.8min (the #1 query at 10 imp/wk), no longer Stellar - bridge-quote-latency: drop benchmark 2026 filler, front-load ms unit - metadata-coverage: drop 'Best X in 2026' listicle pattern, lead with concrete artifacts (logo/desc/twitter/website) - bridge-fee: drop 'in 2026', lead with cost formula (fees + slippage + gas) - aggregator-head-lag: defensive tighten (description was 263c, truncated in SERP) - l2-block-time: trim description from 372 chars (violated schema 320 cap) TSX bug fixes: - alternatives/[slug] BreadcrumbList: position-2 crumb pointed to /benchmarks (wrong) - dropped (no /alternatives index page exists; 2-level breadcrumb is valid) - layout.tsx Organization sameAs: removed fabricated reddit.com/r/openchainbench URL (broken sameAs = soft-fail per Google validator) Expected weekly clicks: 8 -> 37-53 (+30 to +45). l1-finality alone projected 1 -> 25-35 clicks/wk.
This was referenced May 22, 2026
Flotapponnier
added a commit
that referenced
this pull request
May 23, 2026
) Provider lifecycle When prom returns null for a provider's p50/p90/p99 queries this cycle, spec.ts now tags the augmented entry with availability='unavailable' instead of leaving it as a row of zeros that ranks #1 on lower-is-better benches (the 0slot / cardano UX bug). LedgerTable renders the row at the bottom with a soft amber pill 'Currently unavailable' + 'Awaiting next successful scrape'. Stats helpers (computeFieldStats, rankProviders, benchmark-body, chain-headings-summary, bench-template, citation) all exclude unavailable rows so Best/Median/Spread no longer collapse to 0. Slack alerting New /api/cron/health-check route. For each live bench it asks prom two questions per provider: 'did you receive samples in the last 10 min?' and the same query offset 6 min back. The edge (was-live xor is-live) triggers a slack post via SLACK_WEBHOOK_URL env. No external state - we use prom's own series history as the source of truth. Operator setup (one-time, in Vercel project settings) - SLACK_WEBHOOK_URL : the incoming-webhook url (channel of choice) - CRON_SECRET : random string, used to gate the cron route vercel.json schedules the route every 5 minutes. Vercel injects the Authorization: Bearer $CRON_SECRET header automatically.
Flotapponnier
added a commit
that referenced
this pull request
May 23, 2026
5 most-visible tooltip surfaces now use the Hint primitive instead of the browser's default 800 ms title= popup: - home-bench-table — provider initials chips on every home card - benchmark-card — provider initials chips on the /benchmarks grid - distribution-chart — p50 / p90 / p99 percentile markers - ranked-bar-chart — provider name / tag row chip - ledger-table — 'Currently unavailable' badge (had a 2-sentence paragraph stuffed inside a title= attribute, illegible) Hint already shipped in the previous PR; this batch attaches it where the design audit identified the worst UX gaps (provider chips were the agent's #1 example: user sees a 'C' and has to wait 800 ms for the browser to maybe show 'Coinbase'). 120 ms hover delay, instant on focus, ink-on-paper styled - matches Stripe / Linear / Vercel conventions for read-only dashboards. Row-level tooltips (full <li> with methodologyTooltip) keep their native title= for now - wrapping <li> in <Hint>'s <span> would break <ul> semantics. Migrating those needs a small Hint asChild pattern (radix-style cloneElement); deferred to a focused follow-up PR if visible enough to warrant it.
This was referenced May 29, 2026
Flotapponnier
added a commit
that referenced
this pull request
May 29, 2026
…-only fix(ledger): only render #1 chips on the leaderboard row
Flotapponnier
added a commit
that referenced
this pull request
Jun 1, 2026
…, outage, taker, per-asset) (#248) Replace the old findings block (template-based, generic) with 6 new findings that cite specific live numbers from the v2 metrics: - effective fee spread 10x across cohort (Insilico 1.00 → MetaMask 10.00) - price deviation as user-cost signal (Dexari 25.83 bps, 13x median) - volume ≠ user-friendliness (Phantom #1 volume but Insilico cheapest) - two live outage signals (Dexari 19min stale, Okto 24h inactive) - taker share separates aggressive UIs from pro terminals - asset concentration reveals routing strategy (HIP-3 stocks on Dexari/MM) Plus 2 new FAQ entries explaining what price_deviation and last_fill_age actually measure, since they are unique to this bench in the HL space.
Flotapponnier
added a commit
that referenced
this pull request
Jun 2, 2026
* chore(obs): temporary [DRAFT-TRACE] logs on bench draft fallback paths (#147)
Logs every fork that produces a visible draft render to the user:
kv_hit / kv_miss / kv_empty / kv_shape / kv_stale / kv_timeout /
kv_neterr / kv_http / collapse / placeholder_used
Includes slug + timing per call. All prefixed with [DRAFT-TRACE] for
easy grep in Vercel logs. Plain console.warn — no extra deps.
Revert this commit once we have the cause pinned.
* fix(harness): bump Astralane default tip 500k → 1M lamports (#148)
* chore(obs): temporary [DRAFT-TRACE] logs on bench draft fallback paths
Logs every fork that produces a visible draft render to the user:
kv_hit / kv_miss / kv_empty / kv_shape / kv_stale / kv_timeout /
kv_neterr / kv_http / collapse / placeholder_used
Includes slug + timing per call. All prefixed with [DRAFT-TRACE] for
easy grep in Vercel logs. Plain console.warn — no extra deps.
Revert this commit once we have the cause pinned.
* fix(solana-tx-landing harness): bump Astralane default tip to 1M lamports
Sync from the private mobula-api harness. Astralane raised their min
tip floor 500_000 → 1_000_000; keeps the public harness reproducing
the same probe config.
* fix(perf): bump Prom timeout 4s→10s + cron pre-warms bench cache (#149)
Two root causes pinned via [DRAFT-TRACE] logs on staging:
1. Prom queries quantile_over_time(0.99, …[24h]) consistently take
4-8s under load. The 4s client timeout aborted, collapsing benches
to draft when the per-bench KV snapshot wasn't available as fallback.
Bump to 10s — fits the observed worst case with headroom.
2. The /api/cron/health-check route runs every 5 min but only computed
Slack transitions. Add a loadAllBenchmarks() call so the per-bench
unstable_cache stays warm and writeSnapshot() refreshes the KV
snapshot layer. Eliminates the cold-start window where a fresh
Vercel function instance has no in-memory cache AND the snapshot is
stale or missing — exactly the pattern that produces the visible
'bench in draft' symptom users observed.
* fix(logo): wrap 1RPC / Blocknative / Lava / Tenderly / Jito / Astralane in light chip (#150)
* fix(perf): bump Prom timeout 4s→10s + cron pre-warms bench cache
Two root causes pinned via [DRAFT-TRACE] logs on staging:
1. Prom queries quantile_over_time(0.99, …[24h]) consistently take
4-8s under load. The 4s client timeout aborted, collapsing benches
to draft when the per-bench KV snapshot wasn't available as fallback.
Bump to 10s — fits the observed worst case with headroom.
2. The /api/cron/health-check route runs every 5 min but only computed
Slack transitions. Add a loadAllBenchmarks() call so the per-bench
unstable_cache stays warm and writeSnapshot() refreshes the KV
snapshot layer. Eliminates the cold-start window where a fresh
Vercel function instance has no in-memory cache AND the snapshot is
stale or missing — exactly the pattern that produces the visible
'bench in draft' symptom users observed.
* fix(logo): wrap dark-toned provider logos in light chip
1RPC, Blocknative, Lava, Tenderly, Jito and Astralane have single-tone
(mostly dark) artwork that disappears against the page background on
small bench cards. Add to NEEDS_LIGHT_CHIP so they render on a white
chip with a hairline shadow — same treatment as Relay, Debridge, Tonapi.
* fix(logo): also chip dydx v4 and Sky maker (#151)
Same single-tone artwork pattern as the previous batch — invisible on
the bench card background. Wrap in white chip for contrast.
* fix(logo): chip Merkle, Moralis, Nodies — invisible in dark mode (#152)
* fix(logo): introduce NEEDS_DARK_CHIP for white-fill marks (Jito, Astralane, Sky, dydx) (#153)
Verified via SVG fill audit: those 4 logos have fill="white" content,
which the previous white chip rendered as invisible-on-invisible.
Split into two sets — light chip (#fff for dark logos) and dark chip
(#0f172a for white logos).
* fix(build): tolerate all-bench-draft state instead of crashing build (#154)
The safety throw 'every bench draft, refusing to update cache' crashes
next build when Prom is fully unreachable during the build window.
Replace the throw with a [DRAFT-TRACE] warning + return the draft array.
With the snapshot/KV fallback + 10s Prom timeout + cron pre-warm already
mitigating user-visible drafts, this safety net's value is marginal.
* crop 1rpc/tenderly/lava logos to icon-only (#155)
* replace blocknative logo with cleaner vector version (#156)
* feat(bench-005): add 4 providers to network-coverage (#157)
* feat(bench-005): add coinpaprika, covalent, coinstats, sim-dune to network-coverage
* feat(bench-005): add coinpaprika/covalent/coinstats logos
* fix(bench-005): correct CoinStats endpoint path
* feat(products): add coinpaprika/covalent/coinstats/sim-dune/dune to provider registry + swap dune logo to PNG (#158)
* fix(bench-005): rename sim-dune to dune + sync OSS harness mirror with 4 providers (#159)
* feat(bench-027): add Mobula Swap-Send to solana-tx-landing-latency (#160)
* feat(bench-027): add Mobula Swap-Send to solana-tx-landing-latency providers
* feat(harnesses): sync solana-tx-landing OSS mirror with Mobula provider
* fix(bench-027): trim Mobula formula to 240 chars (Zod schema limit) (#161)
* fix(bench-027): rename Mobula Swap-Send to Mobula in product ledger (#162)
* feat(products): consolidate alias slugs (helius-sender/avalanche-official/oracle pairs) into one canonical entry (#163)
* feat(bench): simplify provider display names to brand-only across benches (#164)
* fix(distribution): auto log scale when dynamic range > 50x
The distribution view used a linear scale anchored at max(p99). On benches
with very wide ranges (l1-finality spans 0.4s on TON to 36 min on Monero,
ratio 5370x) every fast row collapsed to a single invisible pixel at the
left edge, making 7 of 9 chains visually indistinguishable. Only the two
slowest rows had visible bars.
Port the same auto-log heuristic ranked-bar-chart already uses: when
max/min > 50, switch to base-10 log projection so each row gets a
proportional, distinguishable position. Footer flags 'log scale' so
the reader knows when it kicks in.
Same component is shared by every bench in distribution view, so this
fixes l1-finality, l2-block-time, validator-yield, perp-fees and any
other bench with wide dynamic range in one shot.
* fix(distribution): anchor left edge at fieldMin, not absolute zero
Narrow-range benches with small absolute minimums collapse every median
dot into a single sliver at the left. solana-tx-landing-latency is the
canonical case: 5 providers all have p50 = 2 slots on a 14-slot field,
so every median lands at 14 % of the track and there is no visible
spread between the leaders.
Switch both linear and log scale to use fieldMin as the left baseline.
The leader now sits at 0 % and laggards spread across the full track.
Footer always shows 'min X' so the reader knows the chart is relative
to observed range, not absolute zero.
* scrub deploy IDs and team scope from public workflow
* feat(bench-001): add GMGN as a provider (Solana-only) + sync harness
GMGN's WebSocket pushes Solana chain-tip events (channel=chain_stat, gk='s', rs=ms:slot) sub-second. Adds it as the 4th provider on the head-lag bench.
YAML:
- New provider entry; queries deliberately omit chain= so per-tab filters auto-hide GMGN on Base/BNB and surface it on All/Solana.
- Methodology + cardinality lines updated.
Harness sync (matches private mobula-api/miniapps/aggregator-head-lag):
- gmgn_monitor.go: chromedp cf_clearance mint + utls HelloChrome_120 ClientHello (h1-only ALPN) + WS subscribe + slot-event parser.
- Dockerfile: chromium + minimal X/font libs for chromedp.
- go.mod: utls + chromedp deps.
- log_buffer.go, config.go, main.go: GMGN_PROXY env + startup banner + goroutine launch.
Validated: bun run validate, typecheck, spec tests all green.
* chore(harness/aggregator-head-lag): drop committed build binary + gitignore it
* scrub slack webhook and grafana URL from metadata-coverage
* remove alerting from metadata-coverage harness
* restore alerting with env-var webhook (envsubst pattern)
* feat(products): wire GMGN logo + registry entry for /products/gmgn
Auto-renders /products/gmgn with brand logo, description and bench appearances now that GMGN appears in bench-001 results. Logo SVG provided by maintainer (12KB, official asset).
* rename relay-link-revenue bench to bridge-revenue
* add 30d range to time-series chart
* drop relay-link-revenue redirect (low traffic, not worth keeping)
* fix bridge-revenue seo_description over 320-char Zod limit
* rpc-capabilities: per-region metric labels + multi-region YAML aggregation
* rpc-capabilities: auto-detect RAILWAY_REPLICA_REGION + 3-region text in YAML
* rpc-capabilities: per-region queries enable region tabs on chart
* metadata-coverage: per-chain editorial (Solana/BNB leaders explicit)
* bridge-fee: flag cross-corridor aggregate; defer per-corridor rewrite
* bridge-quote-latency: flag cross-corridor aggregate; defer per-corridor rewrite
* rpc-capabilities: per-chain editorial (Ethereum/Base/BNB/Arbitrum leaders explicit)
* seo: chain-aware @id suffix in jsonld + page-metadata helpers
* feat(products): add chain-coverage chip + per-chain contract bridge
* seo: bench generateMetadata reads ?chain= and renders chain-honest title + description
* feat(providers): chain-aware ranking + per-chain win counting
* seo: emit per-chain OG + twitter cards via generateImageMetadata
* feat(badge): accept ?chain= and label scope in SVG
* feat(seo): chain-aware metadata + OG + sitemap + JSON-LD
* feat(products page): render per-chain rank chips and clarify wins column
* feat(products+badge): chain-aware ranking + per-chain badge + coverage chip
* core: bestPerChain + worstPerChain stash with chain-aware helpers
Adds per-chain leader/trailer computation in spec.ts that runs only on
the unfiltered All view of benches with dimensions.chain. Fan out one
extra tryLoadLive per chain (excluding 'all') with the chain label
injected, then stash leader + trailer on the Benchmark object.
Exports bestForChain (lib/spec) and leaderForChain (lib/citation) so
the SEO surface + badge endpoints can pull a chain-specific winner
without recomputing.
* bench-001(yaml): rewrite findings/faq/seo_intro for per-chain leaders
Stops surfacing a chain-mix-biased global winner on copy seen across
the site. findings #1 calls out the unfiltered aggregate is biased,
then per-chain bullets break out Solana/Base/BNB leaders separately
via the new placeholders. faq Q1 answers with a per-chain breakdown
instead of one biased winner. seo_intro inlines the per-chain leaders
so the SERP snippet reflects reality.
* feat(core): per-chain best computation + chain-aware placeholders
Threads bestPerChain / worstPerChain through the bench-template
resolver via new {{best_name:chain:X}}, {{best_p50:chain:X}},
{{worst_name:chain:X}}, {{worst_p50:chain:X}} placeholders. The
chain-scoped regex runs before the unfiltered TEMPLATE_RE so a typo
or missing stash leaves the placeholder visible (signal rather than
silent wrong substitution).
Adds 5 happy-path tests covering the new placeholder forms + the
'leave untouched when stash absent' path.
* rpc-capabilities: declare region as top-level dimension for full-page filtering
* fix: persist bestPerChain in KV snapshot + bump cache key
PR #179 added per-chain leader stash (Benchmark.bestPerChain / worstPerChain) but missed two paths:
1) snapshotFromBenchmark only persisted results/extras/sampleSize/lastRunAt — so the KV cold-start fallback reconstructed Benchmarks without those fields, leaving {{best_name:chain:X}} placeholders raw in findings/seo_intro/faq.
2) The bench-unfiltered unstable_cache key was not bumped (still v2), so cached objects from the pre-v3 deploy could surface without the new fields.
Bump SnapshotSchema to _v:2 (writes carry bestPerChain/worstPerChain; reads expose them) and bump the unfiltered cache key to bench-unfiltered-v3 to force a fresh recomputation across the deploy. Followed both ends of the contract: write + read.
* fix: compute bestPerChain for filtered variants too
PR #181 fixed the snapshot + cache key roundtrip. Fixed bestPerChain on the unfiltered hub, but filtered variants still surfaced raw {{best_name:chain:X}} placeholders.
Root cause: bench page (src/app/benchmarks/[slug]/page.tsx:162) pre-fetches 9 variants (3 chains × 3 regions) via getBenchmark(slug, filters) → loadBenchmarkFiltered → specToBenchmark(spec, activeLabels) with isFiltered=true. A1's per-chain compute guard `!isFiltered` skipped the fan-out for those variants, so their findings/seo_intro/faq ended up in the streamed RSC payload with raw placeholders. Switching tabs client-side surfaced them.
Drop the guard. Filtered variants now compute bestPerChain too. Cost: extra 3 Prom roundtrips per filtered variant, but each variant is cached per (slug, filterSig) via loadBenchmarkFiltered's unstable_cache so the cost is paid once per cache window, not per request.
* fix(products): use relative path for badge preview img
CSP img-src 'self' on staging Preview URLs blocks the badge <img> when its src is the prod URL (SITE.url = https://openchainbench.com), surfacing the browser's broken-image glyph on every non-prod origin. Use a relative path for the in-page preview so it always loads under the current origin; keep the absolute URL in the copy-paste HTML since embedders need it to work cross-origin.
* fix: per-chain chips only for providers with real data on that chain
GMGN on /products/gmgn was showing '#2 on Base · #2 on BNB · #2 on Solana' even though GMGN only has Solana data. The chip ranks were derived from the aggregate liveSorted list, so chain-restricted providers got a phantom slot on every chain in the bench's dimensions.
- New Benchmark.providersPerChain field: set of provider slugs that returned live data per chain, computed in spec.ts during the per-chain fan-out (uses the data we were already throwing away).
- rankPerChainForBench restricts the per-chain rank set to providersPerChain[chain] when present, so a Solana-only provider only earns a Solana chip.
- Snapshot v3 + cache key bumped to bench-unfiltered-v4 so the new field survives cold-start KV restores and stale-cache invalidates cleanly across deploys.
* fix(providers): scope rankPerChainForBench to providersPerChain set
The companion change to the previous commit — restricts per-chain rank computation to providers that actually have data on that chain. Without this, a Solana-only provider's aggregate rank leaked into Base/BNB chips on /products/[slug].
* fix: bump outer all-benchmarks cache key to v6
PR #184 added providersPerChain to Benchmark + bumped the inner bench-unfiltered cache key to v4, but missed the OUTER loadAllBenchmarks cache (`all-benchmarks-v5`). The aggregate cache can keep serving v5-era Benchmark slices (no providersPerChain) even after the per-bench cache is fresh, leaving /products/[slug] showing phantom chips for chain-restricted providers.
* timeseries: click-to-exclude with smooth Y-axis re-zoom
* timeseries: drag-to-zoom with smooth Y rescale + reset button
* perf: inherit editorial copy on filtered variants instead of re-computing
PR #182 made every filtered variant run the per-chain Prom fan-out so {{best_name:chain:X}} could resolve in the variant's findings/faq/seoIntro. That quadrupled cold-cache Prom load per page (3 extra queries × 9 pre-fetched variants on benches like aggregator-head-lag), making cold-start visibly sluggish on Vercel preview URLs and pushing some bench loads close to the function timeout.
Cleaner: editorial copy is the same on every tab (it's the same YAML), so filtered variants don't need their own per-chain compute. They inherit findings / faq / seoIntro / abstract / methodology / perChainExplainer / bestPerChain / worstPerChain from the aggregate at the page-level fetch. Per-chain compute stays on the unfiltered hub only.
Net effect: page cold-start drops from 36 query sets to 9, while keeping all the chain-aware placeholders correctly resolved on every tab.
* timeseries: hide hover dots and tooltip rows for excluded providers
* fix(ledger): move chain coverage chips to second row so titles fit
Reported visually: chips ('#1 · BASE', '#1 · BNB CHAIN', '#1 · SOLANA') were rendered inline in the same flex row as the provider logo + name + tag + type badge, eating horizontal space on the name column. Names truncated to 'Mo...', 'Cod...', 'GeckoTerm...' on the bench leaderboard.
Restructure: wrap in a flex-col, keep logo/name/tag/type on top line, push the chips to a second flex-wrap row indented 28 px to align under the name. Chips still md+ only.
* timeseries: per-line hover index + gap-aware dots and tooltip rows
* fix(ledger): only render #1 chips on the bench leaderboard row
Reported visually: on wallet-labels-coverage (11 chain dimensions), every row was showing all 11 chain chips (Ethereum, Solana, BNB Chain, Base, Arbitrum, Polygon, Optimism, TON, Stellar, XRP, Bitcoin) — most rendered as neutral 'not leading on this chain' chips. Total noise: ~44 chips across 4 rows.
Default ChainCoverageChip to leaders-only mode. Non-leader chips are hard-skipped. The bench page chain tabs already provide precise per-chain rankings; this chip is a quick scan aid for 'where does this provider currently top the field' — that signal disappears when buried under 9 neutral chips per row.
leadersOnly=false escape hatch kept so /products/[slug] (or any future consumer) can opt back into the full coverage view.
* retire Leaderboard view from bench pages, default count/usd to Distribution
* Revert "Merge pull request #193 from ChainBench/feat/retire-leaderboard-view"
This reverts commit 9eeb33c601d1e73bf9582d397c82c6b6fad4602f, reversing
changes made to a5e2efb4137d332a9256f1a88454da6b9ef9edb1.
* distribution + ranked-bar: stop clamping sub-1 values to position 0 on log scale
* distribution: position p50 dot at p50Pct% (was stuck at left:0)
* distribution: redesign as percentile whisker with landmark axis
* distribution: add mean marker (outlined ring) to whisker
* audit: refresh MCP allowlist + fix bridge-revenue 404 source links + llms.txt count
* fix(bench): wrap GitHub source link on mobile
Reported visually: the 'Source code github.com/.../tree/main/harnesses/<slug>' link at the bottom of bench pages overflows the viewport on mobile (uppercase + 0.16em tracking inflates an already-80-char URL well past the screen width). Adds break-all on the <p> and max-w-full on the inline <a> so the URL wraps at any character without pushing the page horizontally.
* site-header: interactive 3D logo sphere
* Revert "Merge pull request #202 from ChainBench/feat/3d-site-logo"
This reverts commit bbf8afa96fe9cbccf794f18a99f6dccb2e88b2d0, reversing
changes made to 97754cc4836f129afb9eaeb74bf2f6d5195025f2.
* chore: remove GMGN from bench-001 + assets (#200)
* chore: remove GMGN from bench-001 + assets
Per maintainer's request — GMGN integration is being walked back.
- benchmarks/aggregator-head-lag.yml: drop the gmgn provider entry, revert methodology bullets + seo_intro mention to the pre-GMGN copy. Per-chain editorial placeholders kept (still useful for the bench).
- src/data/provider-registry.ts: drop gmgn entry.
- src/lib/logo-manifest.ts: drop gmgn entry.
- public/logos/gmgn.svg: deleted.
- harnesses/aggregator-head-lag: drop gmgn_monitor.go, GMGN_ENABLED config, GMGN startup banner + goroutine, chromium Dockerfile install, utls dep.
Per-chain architecture (bestPerChain placeholders, products page chips, badge ?chain= param, sitemap per-chain entries) is left in place — it serves metadata-coverage / rpc-capabilities / bridge benches independently of GMGN.
* chore: drop unrelated files accidentally included in prior commit
Worktree leftovers (.claude/worktrees/), local logo-3d experiment, and dev-branch site-header edits got swept in during the GMGN removal commit. Restore them out of this branch so the PR diff is GMGN-only.
* feat(bench-029): add Solana DEX quote latency spec (#203)
New bench measuring wallclock latency of Solana DEX quote APIs (Jupiter, Mobula, OpenOcean, Raydium) every 60s from 3 regions. DFlow added when partnership API key is provided.
Status starts as 'draft' — flip to 'live' after 24h of metric data accumulates in Prom.
Pairs with mobula-monorepo#6608 (harness) + #6609 (Grafana dashboard). Metric names line up with what the harness emits (solana_quote_latency_ms histogram + solana_quote_success gauge + 3 error counters). Methodology + FAQ disclose Mobula co-location bias explicitly.
* chore(harnesses): port solana-quote-latency to public repo + polish seo (#205)
Source code for bench #029 (Solana DEX quote latency) was previously
private in mobula-api/miniapps/. Move it here so the public source link
on the bench page resolves to runnable code.
Also tighten the bench seo_title/description: drop the trailing 'ms'
suffix and add the 2026 year tag, matching the pattern of the more
recent benches (solana-tx-landing-latency, perp-fees, etc.).
* chore: update repo refs from OpenChainBench/OpenChainBench to ChainBench/OpenChainBench (#206)
GitHub auto-redirects the old URL but updating in-place for cleanliness,
SEO, and to keep clone instructions valid. Covers all 19 bench YAMLs,
contributor docs, src/ links, and .github templates.
* bench-029: flip status draft -> live (Prom has samples on all 4 providers across 3 regions) (#207)
* feat(products): wire raydium + openocean logos and registry entries (#208)
Logos added by user at repo root, moved to public/logos/ and wired into
logo-manifest. Provider registry entries placed next to jupiter (Solana
DEX cluster) for bench-029 (solana-dex-quote-latency).
* bench-029: add region dimension + correct hosting claims (#209)
* feat(products): wire raydium + openocean logos and registry entries
Logos added by user at repo root, moved to public/logos/ and wired into
logo-manifest. Provider registry entries placed next to jupiter (Solana
DEX cluster) for bench-029 (solana-dex-quote-latency).
* bench-029: add region dimension + correct unverified hosting claims
- Add dimensions.region block so the page renders region tabs (us-east,
eu-west, sgp) at the top with full-page re-fetch on click, mirroring
the rpc-capabilities pattern.
- Fix unverified 'Mobula on Railway us-east' claim: DNS shows all 4
providers are CDN-fronted (Cloudflare for 3, CloudFront for Jupiter),
so the latency gap is edge-caching policy, not hosting region. Update
the methodology disclosure, the per-provider finding, and the FAQ
entry to reflect what the data actually shows.
* bench-029: warm-path harness + remove false 'Mobula operates' disclosure (#210)
* feat(products): wire raydium + openocean logos and registry entries
Logos added by user at repo root, moved to public/logos/ and wired into
logo-manifest. Provider registry entries placed next to jupiter (Solana
DEX cluster) for bench-029 (solana-dex-quote-latency).
* bench-029: add region dimension + correct unverified hosting claims
- Add dimensions.region block so the page renders region tabs (us-east,
eu-west, sgp) at the top with full-page re-fetch on click, mirroring
the rpc-capabilities pattern.
- Fix unverified 'Mobula on Railway us-east' claim: DNS shows all 4
providers are CDN-fronted (Cloudflare for 3, CloudFront for Jupiter),
so the latency gap is edge-caching policy, not hosting region. Update
the methodology disclosure, the per-provider finding, and the FAQ
entry to reflect what the data actually shows.
* bench-029: warm-path harness + remove false 'Mobula operates' claim
Two coupled changes:
1. Harness switches from cold-path (KeepAlive: -1, DisableKeepAlives:
true, new http.Client per tick) to warm-path: one persistent
http.Client per provider, TCP+TLS reused across the 60s tick, HTTP/2
attempted. The measured number is now steady-state RTT — what a
long-running backend integration actually sees — not the first-call
cold handshake penalty. Shared helper in cmd/monitor/http_client.go.
2. Methodology + FAQ updated to reflect warm-path. Drop the inaccurate
'Mobula operates OpenChainBench' disclosure (Mobula sponsors harnesses
that it hosts on its Railway infra, but does NOT operate or own the
project). Keep the CDN disclosure since DNS verification confirmed
all 4 providers sit behind a CDN — that fact still affects how the
numbers should be read.
* alternatives: pad JSON-LD description to Schema.org Dataset 50-char minimum
* fix: bust bench cache keys to surface bench-029 (#212)
The all-benchmarks-v6 and bench-unfiltered-v4 entries cached before
bench-029 (solana-dex-quote-latency) was merged were still serving from
Vercel Data Cache, so the new bench was 404 on direct URL and absent
from /benchmarks search. Bumping to v5 / v7 forces a fresh build.
* fix(brand): distinct provider colors + sgp region glyph (#213)
- Jupiter switched from #FF7A00 (amber-orange) to #C7F284 (jupiter matrix
green, their secondary brand) so it doesn't collide with mobula's
vivid orange on the solana-dex-quote-latency by-region chart.
- Add raydium (#00C2FF cyan) and openocean (#2563EB deep blue) so they
stop falling through to the editorial palette and reading as similar
orange/violet on the chart.
- Add sgp brand color + isRegion entry alias to ap-southeast. The bench
dimension value is the raw Railway label sgp, but the brand lookup
was only registered under ap-southeast, so the SGP region tab had no
globe glyph and no colored ring (rpc-capabilities had the same issue).
- Rename the SGP region label from 'AP-Southeast' to 'Singapore' on
both bench-029 and rpc-capabilities. AP-Southeast is AWS naming; for
end users Singapore is clearer.
* fix(bench): hide chart's internal region tabs when bench declares dimensions.region (#214)
When a bench YAML has dimensions.region, the top-level Region row already
controls region scoping. The TimeSeriesChart was still rendering its own
internal region tabs because the page only forwarded the region prop when
showChartRegionRow was true (the chart-only fallback path). Now we pass
the top-level region selection down whenever regionOptions exist, so the
chart hides its tabs and matches what the page-level filter selects.
Also rename the AP-Southeast chart-tab label to 'Singapore' to match the
top-level row, and add an alias for the raw 'sgp' region key.
* site-header: 3D logo on desktop, SVG on touch (#215)
* mobile: reliable sticky header + soften dot pattern (#216)
* feat: hyperliquid frontends quality bench № 030 (draft, SQLite cohort retention)
* hl-frontends: COALESCE null retention sum + METRICS_ADDR local-dev override
* mobile: fixed header + dynamic spacer, dots more visible
* bench-029 v2: rotate USDC → long-tail trending tokens (#220)
The anchored SOL → USDC pair Jupiter was serving from CloudFront edge
cache in 35 ms — that measured cache, not routing. Rotating the tokenOut
every tick from Solana volume ranks 6-55 (stables excluded) defeats any
per-pair edge cache and forces every provider to actually search a path.
Harness changes (mirrored to OCB public repo from mobula-api PR #6613):
- trending.go (new): fetches the rotation list every 10 min, picks one
token at random per tick.
- Provider contract adds tokenOut parameter; all 4 adapters refactored
to quote USDC → tokenOut at 100 USDC notional, 1% slippage.
- New counter solana_quote_no_route_total excluded from the latency
histogram (liquidity gap is not a latency outlier).
- isNoRouteSignal matches Jupiter NO_ROUTES_FOUND / TOKEN_NOT_TRADABLE,
Mobula 'No route found' / 'Token not found', Raydium INSUFFICIENT_LIQUIDITY
/ ROUTE_NOT_FOUND. OpenOcean is payload-detected (outAmount==0, dexId<0,
path==null, price_impact==-100%) since it has no string error.
- log_buffer ring is now O(1) instead of O(n) slice-shift.
YAML changes:
- methodology rewritten around the rotation, no-route counter, and
warm-path keep-alive measurement.
- per-provider formulas tightened to the verified response field name
+ the no-route handling.
- FAQ adds entries for the rotation rationale, the no-route counter, and
Raydium's single-venue scope.
* mobile: opaque bg + GPU hint on fixed header (kill notch see-through) (#221)
* hyperliquid-frontends: flip status to live (page renders, awaits real builders.json addresses for data)
* site-logo-switcher: layer SVG under 3D to kill the swap flash
* hl-frontends: shorten disclaimer + 2 methodology bullets to satisfy Zod 500-char cap
* logo-switcher: never paint SVG on desktop (CSS-gate)
* bench-029 v3: switch rotation source from REST volume-ranks to Pulse V2 WS
Sacha's ask was 'pulse trending tokens', not 'top by 24h volume'. v2's
REST volume-ranks pick was a related-but-different signal — established
mid-tier tokens with cumulative volume, not what's pumping right now.
v3 actually wires Pulse V2 in.
Harness changes (mirrored from mobula-api PR):
- New pulse_subscriber.go opens a persistent ws://pulse-v2-api.mobula.io
session, subscribes to the bonded view on Solana, dedupes by mint into
a 30-min sliding pool keyed by lastSeen. Reconnect with 5s -> 60s
exponential backoff. ~50-300 active mints in steady state.
- trending.go now exposes Note() / Forget() (called from the WS handler)
and a Pick() that prefers the pulse pool over the REST snapshot. REST
refresh kept on a 10-min cron as the fallback for Pulse outages.
- scheduler.go boots both goroutines, waits up to 30s for either source
to fill the pool, then ticks. The 'src=PULSE|REST' log per tick makes
the active source observable in real time.
- gorilla/websocket added to go.mod.
YAML methodology rewritten to describe the Pulse rotation. Raydium's
no-route rate disclosure bumped from ~30-50% to ~80% because the
bonded view is mostly Pump.fun graduates living on PumpSwap, not on
Raydium-native pools.
Local validation: 6 ticks observed, 5/6 sourced from PULSE, 6 distinct
tokens (FARTCOIN, tolytown, cbBTC, Electricia, DUCKY, TOPLESS). Jupiter
no-routes on 1, OpenOcean on 1 (Electricia), Raydium on 5 (as expected).
Pool grew 50 -> 61 over the run.
* hl-frontends: real builder addresses (Phantom, Axiom, pvp.trade, Insilico, Defiapp, MetaMask, Dexari, Okto)
* logo-switcher: instant sphere placeholder on desktop while 3D loads
* add logos + registry entries for 8 hyperliquid frontends
* logo: static import 3D, drop dynamic + placeholder
* hl-frontends: registry entries + logo manifest mappings (dropped from earlier squash)
* fix(l1-finality): switch Litecoin from Blockchair to litecoinspace.org (#233)
Blockchair's free-tier IP-blacklisted Railway's egress; the Litecoin
fetcher had been returning HTTP 430 'temporary blacklisted' on every
tick (359 errors/h, l1_finality_health{chain=litecoin}=0, the
last_refresh_timestamp gauge was never even emitted because no fetch
ever succeeded).
Switched to litecoinspace.org (mempool.space-style Esplora API for
Litecoin) — free, no API key required, no IP blacklist. The new fetcher
is one call per tick (GET /api/blocks returns 15 recent blocks with
height + unix timestamp; the 12-confirmations finalized block is
index 12 of the array) vs Blockchair's two-call /stats + /dashboards
flow. Fallback to a second /api/blocks/<height> call if the confirmation
depth ever exceeds the default window.
Verified locally: lag=1912s (= 32 min, matches 12 confirmations × 2.5
min LTC block time), fetch latency 33ms warm-path.
* hl-frontends: switch headline from rolling 24h to last complete UTC day
* fix: mobile overflow on benchmark detail pages
- Tables (ledger, region-grid): min-w only applies from sm: so mobile
shrinks the table inside its overflow-x-auto wrapper instead of
forcing 480/520px and pushing the whole page past the viewport edge
- count-leaderboard summary strip stacks 1-col on mobile, reduces
gap-x so 'RANGE 40 → 306 7 providers' no longer cuts off
- summary-stat removes the 8rem min-width on mobile flex children
- breadcrumb + bench identifier lower tracking on mobile, last crumb
truncates at 60vw so long titles can't push the row off-screen
- Title + subtitle get break-words as safety net
- html { overflow-x: clip } as a final root-level safety net
* fix: site-logo mask id collides between hidden header + visible footer
useId() gives each instance its own mask id. The shared 'c-mask-header'
id was being defined by both the header (display:none on desktop) and
the footer; Safari ignores a mask whose owning SVG is hidden, so the
footer painted an unmasked filled circle instead of the C cutout.
* hl-frontends: walk back 3 days to handle Hyperliquid bucket publish lag
* fix(l1-finality): slow Cardano polling to 60s to stay under Koios free-tier quota
Cardano was DOWN for ~96h: l1_finality_health{chain=cardano}=0, 359 errors/h
via api.koios.rest. Koios responds 200 from any clean IP, but the harness on
Railway hits 429/403 — Koios's free tier is 5,000 req/day/IP, and our default
10s cadence × 2 calls per tick = 17,280 req/day, which burns the quota in
~7h then blacklists the egress IP.
Add a per-chain MinIntervalSeconds field to ChainConfig: when > 0, the
fetchAll dispatcher skips ticks for that chain until enough time has passed.
Cardano set to 60s → 2,880 req/day, safely under the quota. Other chains
keep the global 10s cadence (Solana/Tron/EVM via direct RPC have no such
quota).
Resolution loss is minimal: Cardano blocks are ~20s and 15 confirmations
takes 5 min of wall-clock to finalize, so per-minute polling is plenty.
Verified locally: 2 Cardano ticks in 75s (~60s cadence), lag=478-513s
(= 15 confs × ~20s + empty-slot variance), other chains still 8 ticks in 75s.
* fix(l2-block-time): head-watchdog to catch ghost WS connections
Scroll row went silent: samples_total stuck at 23,887 for >1h, 0
reconnects in the same period, health=1. The reconnect loop never
fires because the pong handler bumps SetReadDeadline on every pong,
so a provider that keeps the WS heartbeat alive while silently
dropping our newHeads subscription wedges the read loop forever.
Scroll already had 109 lifetime reconnects (vs 12-37 on the other 8
L2s), confirming the publicnode → Scroll WS link is the flakiest of
the bunch.
Add a per-connection head-watchdog goroutine: stores the timestamp
of the last received newHead in an atomic int64, checks every 15s,
and forces a conn.Close() if the silence exceeds 90s. Closing the
conn makes ReadMessage return → runChain returns → outer reconnect
loop bumps the counter and dials again. All 9 L2s have block-time
under 15s, so 90s of silence is unambiguously a dead subscription.
Verified locally: 6 Scroll blocks in 30s plus all 8 other L2s
emitting at their natural cadences; watchdog stays quiet under
normal traffic.
* timeseries: interpolate hover-dot Y between sample brackets so it sits on the line
The hover dot was using Math.round to snap to the nearest sample's value,
which disagreed visually with the line during steep slopes. The line is
drawn as straight SVG segments between samples (M…L…L…), so at hover-X
between sample N and N+1 the line is at lerp(v[N], v[N+1], t) — but the
dot was at either v[N] or v[N+1] depending on which way Math.round broke.
Most visible on bench-029 when a provider's quote goes from 1.5s -> 200ms
between two consecutive samples: dot floats above or below the descending
segment.
Fix: floor instead of round to get the bracketing pair, lerp the value at
the fractional position. Dot now lands exactly on the rendered line. The
skip-rendering rule is preserved (no dot when either bracket is a gap).
* fix: stronger mobile overflow safety net for Safari + Telegram WebView
- overflow-x: clip on html AND body (WKWebView ignores it on html alone)
- max-width: 100vw on body to harden against any wide grandchild
- break-words on SEO intro + methodology paragraphs
* fix: shrink region-grid + hide extra ledger cols on mobile
aggregator-head-lag (and other geo-distributed Solana benches) showed
overflow on iPhone because:
- region-grid cells used grid-cols-[1fr_5rem] on every viewport; with
3 regions + a 128px Product column the table intrinsic min was ~404px
> 375px iPhone SE viewport. Now: grid-cols-[1fr_auto] on mobile,
Product col w-20 (80px) instead of w-32, tighter gap, smaller value
font. Stays at the original densities from sm: up.
- ledger Slot delta and Secondary columns were visible on mobile (no
hidden md:table-cell), adding 2 extra mono columns to benches with
hasSlots/secondary. Now hidden on mobile like the other aggregate
columns (p90/p99/Mean/Delta/Success).
* fix: defensive overflow-x: clip on main + article (mobile WebView)
The html/body-level clip from PR #242 is sometimes ignored by iOS WKWebView
(Telegram in-app browser, certain Safari versions) when a deep descendant
forces width > viewport. Adding the clip on main and article means the
guard applies on a non-root box where browsers honour it consistently.
Also w-full + min-w-0 + max-w-full at each level so flex/grid children can
actually shrink instead of inheriting an intrinsic min-content width.
LiveIndicator: drop the 'updated Ns ago' suffix on <sm screens. The
inline-flex container can't wrap internally, so on a 375 px viewport the
whole bench-identifier row was pushed beyond the edge. Dot + Live keeps
the freshness signal without the width tax.
* fix(solana-quote): align region key to sgp so SGP tab renders (#245)
* chore: remove em-dashes / en-dashes from user-facing content (#246)
User wants prose to read less AI-generated. Replaced all em-dashes (—)
and en-dashes (–) in rendered content with commas, periods, colons or
parentheses depending on context. Comment-only dashes (JSDoc, //, /* */)
kept as-is since they never reach the rendered site.
Files touched: 5 YAML benchmark specs, 1 markdown bench page, provider
registry descriptions, OG / Twitter image alt text, alternatives SEO
description, MCP API param description, donut-chart placeholder.
Mobula affiliation audit: clean. The site already reads as a neutral
third-party benchmark project. Mobula appears only as a benchmarked
provider alongside Codex, GeckoTerminal, etc., never as parent / owner.
* feat(hl-frontends): cutover to v2 (sub-minute freshness from local hl-node) (#247)
Swap p50/p99/sample_size/series/mean queries to the *_v2 metrics emitted
by the new hyperliquid-frontends-local harness on OVH SGP. Volume/fees/
effective_fee_bps/fills/fees_per_user now refresh every 30s instead of
the 24-48h bucket lag.
Keep p90 = hl_frontend_d7_retention_pct{} from v1 since v2 doesn't have
SQLite retention state yet (deferred to v2.1).
Replace the success metric (csv_fetch_status was specific to the bucket
fetcher; v2 has no HTTP layer) with a harness-liveness heartbeat:
hl_frontend_local_last_tick_unix_v2 must be < 120s old.
Note: the v2 rolling 24h window started filling at 14:21 UTC today, so
volumes will look low for the next ~22h until the window is fully
populated. After that they'll match v1's full-day numbers.
* feat(hl-frontends): refresh findings + FAQ with v2 bangers (deviation, outage, taker, per-asset) (#248)
Replace the old findings block (template-based, generic) with 6 new
findings that cite specific live numbers from the v2 metrics:
- effective fee spread 10x across cohort (Insilico 1.00 → MetaMask 10.00)
- price deviation as user-cost signal (Dexari 25.83 bps, 13x median)
- volume ≠ user-friendliness (Phantom #1 volume but Insilico cheapest)
- two live outage signals (Dexari 19min stale, Okto 24h inactive)
- taker share separates aggressive UIs from pro terminals
- asset concentration reveals routing strategy (HIP-3 stocks on Dexari/MM)
Plus 2 new FAQ entries explaining what price_deviation and last_fill_age
actually measure, since they are unique to this bench in the HL space.
* feat(bench): switchable companion metric panels under main ledger (#249)
Spec authors can now declare any number of `metric_panels` (up to 8) in
a bench YAML. Each panel names one Prometheus metric; the loader queries
it per provider as `<metric>{<label_key>="<slug>"}` and stores the
scalar values. A new MetricPanelGrid component renders below the main
table with a tab row at the top; clicking a tab re-ranks the cohort by
that panel's metric, applying its higher_is_better direction.
Hyperliquid frontends ships first with 5 panels: slippage proxy, time
since last fill (outage), fills per minute (activity), taker share, and
volume routed. All values come from the local hl-node v2 harness at
sub-minute freshness.
Non-breaking: benches without `metric_panels` render exactly as before.
Cardinality impact on Prom is bounded (5 metrics x 8 providers = 40 new
series across all panels for this bench).
* feat(bench): metric view tabs swap the main chart instead of stacking tables (#250)
Previous PR rendered each metric panel as a separate static table under
the main chart. This commit replaces that with a tab row above the chart
itself: clicking a tab swaps which metric the chart displays (per-builder
multi-line, 24h trailing) so the reader stays in a single visual context
and can compare trends instead of snapshots.
Implementation:
- spec.ts now fetches a 24h series for each panel x provider alongside
the scalar values (`prom.series(query, winSec, 72)`).
- MetricPanel gains an optional `seriesByProvider` field.
- TimeSeriesChart accepts new optional props (seriesOverride,
metricLabelOverride, unitOverride) that swap its data source + header.
- New MetricViewTabs component pure presentation; parent owns active id.
- benchmark-body wires the state, panel description rendered under chart.
- Drops the old MetricPanelGrid table component (no longer used).
Non breaking: benches without `metric_panels` render exactly as before
(the tab row is conditionally rendered only when panels are declared).
* fix(chart): include metric panel id in seriesKey so chart remounts on tab swap (#251)
The Chart child component used `key={seriesKey}` where seriesKey only
encoded range + region. When the parent swapped seriesOverride via a
metric panel tab click, lines useMemo did recompute (override added to
deps) but the Chart child's internal state (drawn paths, slicedLines)
kept the prior render cached. Adding the active panel label to the key
forces a clean remount on tab swap.
* docs(hl-frontends): refresh disclaimer, seo_intro, abstract, methodology, faq for v2 pipeline (#252)
Rewrites the bench documentation to describe the local hl-node fills
stream rather than the deprecated daily CSV bucket. The methodology
list now covers all 9 metrics actually emitted (effective fee, fees
per user, price deviation, last fill age, fills per minute, taker
share, per asset top three, plus the heartbeat and rolling window
mechanics). FAQ entries that referenced the CSV URL or the old
frontend list (OKX, GMGN, Dexterity, KGEN, ASXN) updated to match
the current builders.json (Phantom, Axiom, pvp.trade, Insilico,
DefiApp, MetaMask, Dexari, Okto). Source URL points at the new
miniapps/hyperliquid-frontends-local harness.
No em dashes anywhere in prose per content guideline. All character
caps respected.
* chore: safe cleanup — dead code, stale lint suppressions, unused vars (#253)
Audit-driven cleanup verified to have zero visible impact on the site.
Deletes (confirmed zero imports across src/):
- src/components/benchmark-table.tsx (201 LOC, never imported)
- src/lib/methodology-tooltip.ts (37 LOC, helper never wired up)
- src/lib/utils.ts (cn() helper, never called)
- CURRENT_ISSUE and ISSUE_DATE_LONG exports in site.ts
Lint cleanups:
- Drop three stale eslint-disable directives (apple-icon, icon,
benchmarks/[slug]/page) for rules that no longer fire
- Fix two unescaped apostrophes in mcp/page.tsx
- Remove unused activeTemplate variable in share-section.tsx
- Drop padB / padR from a useMemo deps array in time-series-chart.tsx
where they were never read in the closure
Kept on purpose despite audit suggestions:
- .card CSS class (used 5x by card / live / benchmark-grid / body)
- --color-accent-soft and --color-banner-* tokens (used by Tailwind
utilities in benchmark-grid, view-switcher, home-bench-table,
site-banner)
- GithubIcon custom SVG (lucide-react dropped its GitHub brand mark)
- Static three.js import on the 3D logo (replacing with next/dynamic
re-introduces the SVG flash that PR #231 deliberately removed)
- Hook-rule and ref-during-render lints in benchmark-body /
time-series-chart: real refactors, out of scope for a zero-impact
cleanup, deferred to a follow-up
Verified: pnpm typecheck clean, pnpm build green.
* hl-frontends: expand providers 8 → 60 (DefiLlama registry sync) (#254)
* chore: remove SiteBanner (#256)
User reports the banner leaves a phantom gap in Telegram WebView even
once dismissed (localStorage doesn't always persist in in-app browsers,
and the wrapper's padding still measures even when the banner returns
null). Drop the component, drop the import, drop the spacer's banner-
related comment + the now-irrelevant initial height estimate.
* chart: add Top 5/10/20/All selector when cohort > 10 providers (#257)
* chart: size top-N selector off registered cohort, not emitted series (#258)
* chore: delete verified dead code (real pass, not agent guesses) (#255)
This time I greped every file myself instead of trusting agents.
Deletes (zero usages confirmed across src/):
- src/components/live-dashboard.tsx — 6-line re-export shim; inlined the
import in app/page.tsx so the shim has nothing left to redirect.
- src/lib/brand.ts: brandIsDark() — never called anywhere.
- src/lib/live/format.ts: fmtAge() — never called. live-indicator.tsx
has its own formatAge() with day handling that fmtAge lacks, so they
weren't an actual duplicate either.
- src/components/chain-coverage-chip.tsx: leaderChains(),
perChainRankSegments(), chainLabelFor(), chainLeader() — exported but
never imported AND never called from within the same file. TypeScript
noUnusedLocals confirms they were stranded.
Visibility tightened:
- src/lib/brand.ts: isLight() now non-exported (only used by
chipTextColor in the same file).
Drive-by cleanup that fell out:
- Dropped ProviderResult import in chain-coverage-chip.tsx (only the
deleted helpers used it).
Verified clean: pnpm typecheck + pnpm tsc --noEmit --noUnusedLocals
--noUnusedParameters + pnpm build all pass. Net: -79 lines.
* fix: drop overflow-x clip from body so position: fixed sticks on WKWebView (#259)
User reports a 1-2 px gap at the top of the header in Telegram in-app
Safari that lets page content peek through during scroll. Root cause:
body had overflow-x: clip (PR #242 safety net) and WebKit treats clip
on body as creating a containing block for position: fixed descendants.
The fixed header was then anchored to body's top edge, not the viewport,
and momentum scroll desynced it from the URL-bar animation, producing
the visible slit.
Move the overflow-x clip safety net to <html> only. <main> and <article>
already carry overflow-x-clip (PR #244) so page-level horizontal overflow
remains contained without needing it on body. Drop the translate3d hint
on the header — it was paired with the clip workaround and is not needed
once fixed positions cleanly against the viewport.
* hl-frontends: 60-builder registry, real logos, methodology refresh, availability backstop (#260)
* hotfix(hl-frontends): trim methodology line 8 to <=500 chars (Zod limit) (#261)
* fix: switch header to position: sticky (iOS 26 WebKit fixed jitter bug) (#262)
Web search confirmed user is not alone. WebKit bug 297779 reports that
on iOS 26 Safari + WKWebView (Telegram in-app browser, Chrome iOS), any
position: fixed element drifts 10-24 px when scroll direction reverses,
exposing the page under the header through a thin gap. Mastodon and
LinkedIn ship the same symptom. Apple acknowledged the bug, partially
fixed it in iOS 26.1 but residual reports persist into Dec 2025.
position: sticky uses a different render path in WebKit that does not
trip the jitter heuristic. It is also simpler:
- no spacer div needed (the element stays in normal flow)
- no useRef + ResizeObserver to keep the spacer in sync
- no headerH state
Net: -25 lines on top of fixing the cosmetic gap. Desktop and standalone
Safari behavior are identical, sticky just renders more reliably in
WebViews.
Refs:
- https://bugs.webkit.org/show_bug.cgi?id=297779
- https://github.com/mastodon/mastodon/issues/36144
- https://github.com/TelegramMessenger/Telegram-iOS/issues/1748
* fix: drop rows with zero headline value from ledger and ranked-bar (#264)
* fix: switch header to position: sticky (iOS 26 WebKit fixed jitter bug)
Web search confirmed user is not alone. WebKit bug 297779 reports that
on iOS 26 Safari + WKWebView (Telegram in-app browser, Chrome iOS), any
position: fixed element drifts 10-24 px when scroll direction reverses,
exposing the page under the header through a thin gap. Mastodon and
LinkedIn ship the same symptom. Apple acknowledged the bug, partially
fixed it in iOS 26.1 but residual reports persist into Dec 2025.
position: sticky uses a different render path in WebKit that does not
trip the jitter heuristic. It is also simpler:
- no spacer div needed (the element stays in normal flow)
- no useRef + ResizeObserver to keep the spacer in sync
- no headerH state
Net: -25 lines on top of fixing the cosmetic gap. Desktop and standalone
Safari behavior are identical, sticky just renders more reliably in
WebViews.
Refs:
- https://bugs.webkit.org/show_bug.cgi?id=297779
- https://github.com/mastodon/mastodon/issues/36144
- https://github.com/TelegramMessenger/Telegram-iOS/issues/1748
* ranked-bar+ledger: add Top-N selector and drop unscored rows
* ledger+ranked-bar: drop rows with zero headline value across p50/p90/p99
* chart: slice seriesOverride by range so panel tabs respect 1h/6h (#265)
* chart: Top-N buttons reflect available data (hide useless options) (#266)
* fix: drop sticky on mobile (iOS 26 WebKit jitter workaround) (#267)
position: sticky and position: fixed both jitter 10-24 px on iOS 26 when
scroll direction changes (bugs.webkit.org/297779). Apple acknowledged
the bug, partial fix in 26.1, residual reports into late 2025. Mastodon
and LinkedIn ship the same symptom and concluded it is unfixable from
the site side.
Mitigation: keep sticky only on md+ where the desktop render path is
clean. On mobile the header stays in normal flow, scrolls away with the
page, and the jitter cannot appear because nothing is positioned. Same
pattern as most mobile sites — pull to top to see the nav.
Single class change: `sticky top-0` -> `relative md:sticky md:top-0`.
* fix: keep sticky header on regular mobile, only drop it in iOS WebViews (#268)
Previous PR (#267) dropped sticky on all mobile to dodge the iOS 26
WebKit jitter bug. Overkill — the symptom only manifests visibly inside
in-app WebViews (Telegram, Instagram, Facebook, Twitter, TikTok) where
the host's own URL-bar overlay animates on top of WebKit's misposition.
Regular mobile Safari and Chrome iOS render sticky cleanly enough for
end users.
Mechanism:
- Inline script in layout.tsx head detects iOS WebView via UA (no
`Safari/` token on an iOS device = WKWebView in-app host) and adds
the `ios-webview` class on <html> before React hydrates.
- globals.css scopes a non-sticky override to that class on the site
header at < md viewports.
- site-header.tsx exposes a stable identifier class (`site-header-root`)
and stays sticky-top-0 by default everywhere else.
No flash on first paint because the class lands during the same render
tick as the dark-mode detection script next to it. Build + typecheck
clean.
* fix(chart): Top-N selector on all 4 chart views (ranked, time, distribution, donut) (#269)
* feat(bench): network fees — current native transfer cost USD across 11 L1 chains
* chart: Top-N selector on all 4 chart views via shared hook+component
* ledger: follow active panel — sort/filter/value per panel metric (#271)
* wallet-labels-coverage: drop All chains aggregate tab (#272)
* feat(bench): network fees — current native transfer cost USD across 11 L1 chains
* chart: Top-N selector on all 4 chart views via shared hook+component
* wallet-labels-coverage: drop All chains aggregate tab
* feat(bench): network fees — current native transfer cost USD across 11 L1 chains (#270)
* bench-body: pass activePanel to LedgerTable + dynamic ledger title (#273)
* hotfix(bench-body): restore ledger title template literal + closing brace (#276)
* feat(network-fees): L1/L2 toggle + 9 L2 providers
Flotapponnier
added a commit
that referenced
this pull request
Jun 5, 2026
…+ accumulated improvements) (#316) * chore(obs): temporary [DRAFT-TRACE] logs on bench draft fallback paths (#147) Logs every fork that produces a visible draft render to the user: kv_hit / kv_miss / kv_empty / kv_shape / kv_stale / kv_timeout / kv_neterr / kv_http / collapse / placeholder_used Includes slug + timing per call. All prefixed with [DRAFT-TRACE] for easy grep in Vercel logs. Plain console.warn — no extra deps. Revert this commit once we have the cause pinned. * fix(harness): bump Astralane default tip 500k → 1M lamports (#148) * chore(obs): temporary [DRAFT-TRACE] logs on bench draft fallback paths Logs every fork that produces a visible draft render to the user: kv_hit / kv_miss / kv_empty / kv_shape / kv_stale / kv_timeout / kv_neterr / kv_http / collapse / placeholder_used Includes slug + timing per call. All prefixed with [DRAFT-TRACE] for easy grep in Vercel logs. Plain console.warn — no extra deps. Revert this commit once we have the cause pinned. * fix(solana-tx-landing harness): bump Astralane default tip to 1M lamports Sync from the private mobula-api harness. Astralane raised their min tip floor 500_000 → 1_000_000; keeps the public harness reproducing the same probe config. * fix(perf): bump Prom timeout 4s→10s + cron pre-warms bench cache (#149) Two root causes pinned via [DRAFT-TRACE] logs on staging: 1. Prom queries quantile_over_time(0.99, …[24h]) consistently take 4-8s under load. The 4s client timeout aborted, collapsing benches to draft when the per-bench KV snapshot wasn't available as fallback. Bump to 10s — fits the observed worst case with headroom. 2. The /api/cron/health-check route runs every 5 min but only computed Slack transitions. Add a loadAllBenchmarks() call so the per-bench unstable_cache stays warm and writeSnapshot() refreshes the KV snapshot layer. Eliminates the cold-start window where a fresh Vercel function instance has no in-memory cache AND the snapshot is stale or missing — exactly the pattern that produces the visible 'bench in draft' symptom users observed. * fix(logo): wrap 1RPC / Blocknative / Lava / Tenderly / Jito / Astralane in light chip (#150) * fix(perf): bump Prom timeout 4s→10s + cron pre-warms bench cache Two root causes pinned via [DRAFT-TRACE] logs on staging: 1. Prom queries quantile_over_time(0.99, …[24h]) consistently take 4-8s under load. The 4s client timeout aborted, collapsing benches to draft when the per-bench KV snapshot wasn't available as fallback. Bump to 10s — fits the observed worst case with headroom. 2. The /api/cron/health-check route runs every 5 min but only computed Slack transitions. Add a loadAllBenchmarks() call so the per-bench unstable_cache stays warm and writeSnapshot() refreshes the KV snapshot layer. Eliminates the cold-start window where a fresh Vercel function instance has no in-memory cache AND the snapshot is stale or missing — exactly the pattern that produces the visible 'bench in draft' symptom users observed. * fix(logo): wrap dark-toned provider logos in light chip 1RPC, Blocknative, Lava, Tenderly, Jito and Astralane have single-tone (mostly dark) artwork that disappears against the page background on small bench cards. Add to NEEDS_LIGHT_CHIP so they render on a white chip with a hairline shadow — same treatment as Relay, Debridge, Tonapi. * fix(logo): also chip dydx v4 and Sky maker (#151) Same single-tone artwork pattern as the previous batch — invisible on the bench card background. Wrap in white chip for contrast. * fix(logo): chip Merkle, Moralis, Nodies — invisible in dark mode (#152) * fix(logo): introduce NEEDS_DARK_CHIP for white-fill marks (Jito, Astralane, Sky, dydx) (#153) Verified via SVG fill audit: those 4 logos have fill="white" content, which the previous white chip rendered as invisible-on-invisible. Split into two sets — light chip (#fff for dark logos) and dark chip (#0f172a for white logos). * fix(build): tolerate all-bench-draft state instead of crashing build (#154) The safety throw 'every bench draft, refusing to update cache' crashes next build when Prom is fully unreachable during the build window. Replace the throw with a [DRAFT-TRACE] warning + return the draft array. With the snapshot/KV fallback + 10s Prom timeout + cron pre-warm already mitigating user-visible drafts, this safety net's value is marginal. * crop 1rpc/tenderly/lava logos to icon-only (#155) * replace blocknative logo with cleaner vector version (#156) * feat(bench-005): add 4 providers to network-coverage (#157) * feat(bench-005): add coinpaprika, covalent, coinstats, sim-dune to network-coverage * feat(bench-005): add coinpaprika/covalent/coinstats logos * fix(bench-005): correct CoinStats endpoint path * feat(products): add coinpaprika/covalent/coinstats/sim-dune/dune to provider registry + swap dune logo to PNG (#158) * fix(bench-005): rename sim-dune to dune + sync OSS harness mirror with 4 providers (#159) * feat(bench-027): add Mobula Swap-Send to solana-tx-landing-latency (#160) * feat(bench-027): add Mobula Swap-Send to solana-tx-landing-latency providers * feat(harnesses): sync solana-tx-landing OSS mirror with Mobula provider * fix(bench-027): trim Mobula formula to 240 chars (Zod schema limit) (#161) * fix(bench-027): rename Mobula Swap-Send to Mobula in product ledger (#162) * feat(products): consolidate alias slugs (helius-sender/avalanche-official/oracle pairs) into one canonical entry (#163) * feat(bench): simplify provider display names to brand-only across benches (#164) * fix(distribution): auto log scale when dynamic range > 50x The distribution view used a linear scale anchored at max(p99). On benches with very wide ranges (l1-finality spans 0.4s on TON to 36 min on Monero, ratio 5370x) every fast row collapsed to a single invisible pixel at the left edge, making 7 of 9 chains visually indistinguishable. Only the two slowest rows had visible bars. Port the same auto-log heuristic ranked-bar-chart already uses: when max/min > 50, switch to base-10 log projection so each row gets a proportional, distinguishable position. Footer flags 'log scale' so the reader knows when it kicks in. Same component is shared by every bench in distribution view, so this fixes l1-finality, l2-block-time, validator-yield, perp-fees and any other bench with wide dynamic range in one shot. * fix(distribution): anchor left edge at fieldMin, not absolute zero Narrow-range benches with small absolute minimums collapse every median dot into a single sliver at the left. solana-tx-landing-latency is the canonical case: 5 providers all have p50 = 2 slots on a 14-slot field, so every median lands at 14 % of the track and there is no visible spread between the leaders. Switch both linear and log scale to use fieldMin as the left baseline. The leader now sits at 0 % and laggards spread across the full track. Footer always shows 'min X' so the reader knows the chart is relative to observed range, not absolute zero. * scrub deploy IDs and team scope from public workflow * feat(bench-001): add GMGN as a provider (Solana-only) + sync harness GMGN's WebSocket pushes Solana chain-tip events (channel=chain_stat, gk='s', rs=ms:slot) sub-second. Adds it as the 4th provider on the head-lag bench. YAML: - New provider entry; queries deliberately omit chain= so per-tab filters auto-hide GMGN on Base/BNB and surface it on All/Solana. - Methodology + cardinality lines updated. Harness sync (matches private mobula-api/miniapps/aggregator-head-lag): - gmgn_monitor.go: chromedp cf_clearance mint + utls HelloChrome_120 ClientHello (h1-only ALPN) + WS subscribe + slot-event parser. - Dockerfile: chromium + minimal X/font libs for chromedp. - go.mod: utls + chromedp deps. - log_buffer.go, config.go, main.go: GMGN_PROXY env + startup banner + goroutine launch. Validated: bun run validate, typecheck, spec tests all green. * chore(harness/aggregator-head-lag): drop committed build binary + gitignore it * scrub slack webhook and grafana URL from metadata-coverage * remove alerting from metadata-coverage harness * restore alerting with env-var webhook (envsubst pattern) * feat(products): wire GMGN logo + registry entry for /products/gmgn Auto-renders /products/gmgn with brand logo, description and bench appearances now that GMGN appears in bench-001 results. Logo SVG provided by maintainer (12KB, official asset). * rename relay-link-revenue bench to bridge-revenue * add 30d range to time-series chart * drop relay-link-revenue redirect (low traffic, not worth keeping) * fix bridge-revenue seo_description over 320-char Zod limit * rpc-capabilities: per-region metric labels + multi-region YAML aggregation * rpc-capabilities: auto-detect RAILWAY_REPLICA_REGION + 3-region text in YAML * rpc-capabilities: per-region queries enable region tabs on chart * metadata-coverage: per-chain editorial (Solana/BNB leaders explicit) * bridge-fee: flag cross-corridor aggregate; defer per-corridor rewrite * bridge-quote-latency: flag cross-corridor aggregate; defer per-corridor rewrite * rpc-capabilities: per-chain editorial (Ethereum/Base/BNB/Arbitrum leaders explicit) * seo: chain-aware @id suffix in jsonld + page-metadata helpers * feat(products): add chain-coverage chip + per-chain contract bridge * seo: bench generateMetadata reads ?chain= and renders chain-honest title + description * feat(providers): chain-aware ranking + per-chain win counting * seo: emit per-chain OG + twitter cards via generateImageMetadata * feat(badge): accept ?chain= and label scope in SVG * feat(seo): chain-aware metadata + OG + sitemap + JSON-LD * feat(products page): render per-chain rank chips and clarify wins column * feat(products+badge): chain-aware ranking + per-chain badge + coverage chip * core: bestPerChain + worstPerChain stash with chain-aware helpers Adds per-chain leader/trailer computation in spec.ts that runs only on the unfiltered All view of benches with dimensions.chain. Fan out one extra tryLoadLive per chain (excluding 'all') with the chain label injected, then stash leader + trailer on the Benchmark object. Exports bestForChain (lib/spec) and leaderForChain (lib/citation) so the SEO surface + badge endpoints can pull a chain-specific winner without recomputing. * bench-001(yaml): rewrite findings/faq/seo_intro for per-chain leaders Stops surfacing a chain-mix-biased global winner on copy seen across the site. findings #1 calls out the unfiltered aggregate is biased, then per-chain bullets break out Solana/Base/BNB leaders separately via the new placeholders. faq Q1 answers with a per-chain breakdown instead of one biased winner. seo_intro inlines the per-chain leaders so the SERP snippet reflects reality. * feat(core): per-chain best computation + chain-aware placeholders Threads bestPerChain / worstPerChain through the bench-template resolver via new {{best_name:chain:X}}, {{best_p50:chain:X}}, {{worst_name:chain:X}}, {{worst_p50:chain:X}} placeholders. The chain-scoped regex runs before the unfiltered TEMPLATE_RE so a typo or missing stash leaves the placeholder visible (signal rather than silent wrong substitution). Adds 5 happy-path tests covering the new placeholder forms + the 'leave untouched when stash absent' path. * rpc-capabilities: declare region as top-level dimension for full-page filtering * fix: persist bestPerChain in KV snapshot + bump cache key PR #179 added per-chain leader stash (Benchmark.bestPerChain / worstPerChain) but missed two paths: 1) snapshotFromBenchmark only persisted results/extras/sampleSize/lastRunAt — so the KV cold-start fallback reconstructed Benchmarks without those fields, leaving {{best_name:chain:X}} placeholders raw in findings/seo_intro/faq. 2) The bench-unfiltered unstable_cache key was not bumped (still v2), so cached objects from the pre-v3 deploy could surface without the new fields. Bump SnapshotSchema to _v:2 (writes carry bestPerChain/worstPerChain; reads expose them) and bump the unfiltered cache key to bench-unfiltered-v3 to force a fresh recomputation across the deploy. Followed both ends of the contract: write + read. * fix: compute bestPerChain for filtered variants too PR #181 fixed the snapshot + cache key roundtrip. Fixed bestPerChain on the unfiltered hub, but filtered variants still surfaced raw {{best_name:chain:X}} placeholders. Root cause: bench page (src/app/benchmarks/[slug]/page.tsx:162) pre-fetches 9 variants (3 chains × 3 regions) via getBenchmark(slug, filters) → loadBenchmarkFiltered → specToBenchmark(spec, activeLabels) with isFiltered=true. A1's per-chain compute guard `!isFiltered` skipped the fan-out for those variants, so their findings/seo_intro/faq ended up in the streamed RSC payload with raw placeholders. Switching tabs client-side surfaced them. Drop the guard. Filtered variants now compute bestPerChain too. Cost: extra 3 Prom roundtrips per filtered variant, but each variant is cached per (slug, filterSig) via loadBenchmarkFiltered's unstable_cache so the cost is paid once per cache window, not per request. * fix(products): use relative path for badge preview img CSP img-src 'self' on staging Preview URLs blocks the badge <img> when its src is the prod URL (SITE.url = https://openchainbench.com), surfacing the browser's broken-image glyph on every non-prod origin. Use a relative path for the in-page preview so it always loads under the current origin; keep the absolute URL in the copy-paste HTML since embedders need it to work cross-origin. * fix: per-chain chips only for providers with real data on that chain GMGN on /products/gmgn was showing '#2 on Base · #2 on BNB · #2 on Solana' even though GMGN only has Solana data. The chip ranks were derived from the aggregate liveSorted list, so chain-restricted providers got a phantom slot on every chain in the bench's dimensions. - New Benchmark.providersPerChain field: set of provider slugs that returned live data per chain, computed in spec.ts during the per-chain fan-out (uses the data we were already throwing away). - rankPerChainForBench restricts the per-chain rank set to providersPerChain[chain] when present, so a Solana-only provider only earns a Solana chip. - Snapshot v3 + cache key bumped to bench-unfiltered-v4 so the new field survives cold-start KV restores and stale-cache invalidates cleanly across deploys. * fix(providers): scope rankPerChainForBench to providersPerChain set The companion change to the previous commit — restricts per-chain rank computation to providers that actually have data on that chain. Without this, a Solana-only provider's aggregate rank leaked into Base/BNB chips on /products/[slug]. * fix: bump outer all-benchmarks cache key to v6 PR #184 added providersPerChain to Benchmark + bumped the inner bench-unfiltered cache key to v4, but missed the OUTER loadAllBenchmarks cache (`all-benchmarks-v5`). The aggregate cache can keep serving v5-era Benchmark slices (no providersPerChain) even after the per-bench cache is fresh, leaving /products/[slug] showing phantom chips for chain-restricted providers. * timeseries: click-to-exclude with smooth Y-axis re-zoom * timeseries: drag-to-zoom with smooth Y rescale + reset button * perf: inherit editorial copy on filtered variants instead of re-computing PR #182 made every filtered variant run the per-chain Prom fan-out so {{best_name:chain:X}} could resolve in the variant's findings/faq/seoIntro. That quadrupled cold-cache Prom load per page (3 extra queries × 9 pre-fetched variants on benches like aggregator-head-lag), making cold-start visibly sluggish on Vercel preview URLs and pushing some bench loads close to the function timeout. Cleaner: editorial copy is the same on every tab (it's the same YAML), so filtered variants don't need their own per-chain compute. They inherit findings / faq / seoIntro / abstract / methodology / perChainExplainer / bestPerChain / worstPerChain from the aggregate at the page-level fetch. Per-chain compute stays on the unfiltered hub only. Net effect: page cold-start drops from 36 query sets to 9, while keeping all the chain-aware placeholders correctly resolved on every tab. * timeseries: hide hover dots and tooltip rows for excluded providers * fix(ledger): move chain coverage chips to second row so titles fit Reported visually: chips ('#1 · BASE', '#1 · BNB CHAIN', '#1 · SOLANA') were rendered inline in the same flex row as the provider logo + name + tag + type badge, eating horizontal space on the name column. Names truncated to 'Mo...', 'Cod...', 'GeckoTerm...' on the bench leaderboard. Restructure: wrap in a flex-col, keep logo/name/tag/type on top line, push the chips to a second flex-wrap row indented 28 px to align under the name. Chips still md+ only. * timeseries: per-line hover index + gap-aware dots and tooltip rows * fix(ledger): only render #1 chips on the bench leaderboard row Reported visually: on wallet-labels-coverage (11 chain dimensions), every row was showing all 11 chain chips (Ethereum, Solana, BNB Chain, Base, Arbitrum, Polygon, Optimism, TON, Stellar, XRP, Bitcoin) — most rendered as neutral 'not leading on this chain' chips. Total noise: ~44 chips across 4 rows. Default ChainCoverageChip to leaders-only mode. Non-leader chips are hard-skipped. The bench page chain tabs already provide precise per-chain rankings; this chip is a quick scan aid for 'where does this provider currently top the field' — that signal disappears when buried under 9 neutral chips per row. leadersOnly=false escape hatch kept so /products/[slug] (or any future consumer) can opt back into the full coverage view. * retire Leaderboard view from bench pages, default count/usd to Distribution * Revert "Merge pull request #193 from ChainBench/feat/retire-leaderboard-view" This reverts commit 9eeb33c601d1e73bf9582d397c82c6b6fad4602f, reversing changes made to a5e2efb4137d332a9256f1a88454da6b9ef9edb1. * distribution + ranked-bar: stop clamping sub-1 values to position 0 on log scale * distribution: position p50 dot at p50Pct% (was stuck at left:0) * distribution: redesign as percentile whisker with landmark axis * distribution: add mean marker (outlined ring) to whisker * audit: refresh MCP allowlist + fix bridge-revenue 404 source links + llms.txt count * fix(bench): wrap GitHub source link on mobile Reported visually: the 'Source code github.com/.../tree/main/harnesses/<slug>' link at the bottom of bench pages overflows the viewport on mobile (uppercase + 0.16em tracking inflates an already-80-char URL well past the screen width). Adds break-all on the <p> and max-w-full on the inline <a> so the URL wraps at any character without pushing the page horizontally. * site-header: interactive 3D logo sphere * Revert "Merge pull request #202 from ChainBench/feat/3d-site-logo" This reverts commit bbf8afa96fe9cbccf794f18a99f6dccb2e88b2d0, reversing changes made to 97754cc4836f129afb9eaeb74bf2f6d5195025f2. * chore: remove GMGN from bench-001 + assets (#200) * chore: remove GMGN from bench-001 + assets Per maintainer's request — GMGN integration is being walked back. - benchmarks/aggregator-head-lag.yml: drop the gmgn provider entry, revert methodology bullets + seo_intro mention to the pre-GMGN copy. Per-chain editorial placeholders kept (still useful for the bench). - src/data/provider-registry.ts: drop gmgn entry. - src/lib/logo-manifest.ts: drop gmgn entry. - public/logos/gmgn.svg: deleted. - harnesses/aggregator-head-lag: drop gmgn_monitor.go, GMGN_ENABLED config, GMGN startup banner + goroutine, chromium Dockerfile install, utls dep. Per-chain architecture (bestPerChain placeholders, products page chips, badge ?chain= param, sitemap per-chain entries) is left in place — it serves metadata-coverage / rpc-capabilities / bridge benches independently of GMGN. * chore: drop unrelated files accidentally included in prior commit Worktree leftovers (.claude/worktrees/), local logo-3d experiment, and dev-branch site-header edits got swept in during the GMGN removal commit. Restore them out of this branch so the PR diff is GMGN-only. * feat(bench-029): add Solana DEX quote latency spec (#203) New bench measuring wallclock latency of Solana DEX quote APIs (Jupiter, Mobula, OpenOcean, Raydium) every 60s from 3 regions. DFlow added when partnership API key is provided. Status starts as 'draft' — flip to 'live' after 24h of metric data accumulates in Prom. Pairs with mobula-monorepo#6608 (harness) + #6609 (Grafana dashboard). Metric names line up with what the harness emits (solana_quote_latency_ms histogram + solana_quote_success gauge + 3 error counters). Methodology + FAQ disclose Mobula co-location bias explicitly. * chore(harnesses): port solana-quote-latency to public repo + polish seo (#205) Source code for bench #029 (Solana DEX quote latency) was previously private in mobula-api/miniapps/. Move it here so the public source link on the bench page resolves to runnable code. Also tighten the bench seo_title/description: drop the trailing 'ms' suffix and add the 2026 year tag, matching the pattern of the more recent benches (solana-tx-landing-latency, perp-fees, etc.). * chore: update repo refs from OpenChainBench/OpenChainBench to ChainBench/OpenChainBench (#206) GitHub auto-redirects the old URL but updating in-place for cleanliness, SEO, and to keep clone instructions valid. Covers all 19 bench YAMLs, contributor docs, src/ links, and .github templates. * bench-029: flip status draft -> live (Prom has samples on all 4 providers across 3 regions) (#207) * feat(products): wire raydium + openocean logos and registry entries (#208) Logos added by user at repo root, moved to public/logos/ and wired into logo-manifest. Provider registry entries placed next to jupiter (Solana DEX cluster) for bench-029 (solana-dex-quote-latency). * bench-029: add region dimension + correct hosting claims (#209) * feat(products): wire raydium + openocean logos and registry entries Logos added by user at repo root, moved to public/logos/ and wired into logo-manifest. Provider registry entries placed next to jupiter (Solana DEX cluster) for bench-029 (solana-dex-quote-latency). * bench-029: add region dimension + correct unverified hosting claims - Add dimensions.region block so the page renders region tabs (us-east, eu-west, sgp) at the top with full-page re-fetch on click, mirroring the rpc-capabilities pattern. - Fix unverified 'Mobula on Railway us-east' claim: DNS shows all 4 providers are CDN-fronted (Cloudflare for 3, CloudFront for Jupiter), so the latency gap is edge-caching policy, not hosting region. Update the methodology disclosure, the per-provider finding, and the FAQ entry to reflect what the data actually shows. * bench-029: warm-path harness + remove false 'Mobula operates' disclosure (#210) * feat(products): wire raydium + openocean logos and registry entries Logos added by user at repo root, moved to public/logos/ and wired into logo-manifest. Provider registry entries placed next to jupiter (Solana DEX cluster) for bench-029 (solana-dex-quote-latency). * bench-029: add region dimension + correct unverified hosting claims - Add dimensions.region block so the page renders region tabs (us-east, eu-west, sgp) at the top with full-page re-fetch on click, mirroring the rpc-capabilities pattern. - Fix unverified 'Mobula on Railway us-east' claim: DNS shows all 4 providers are CDN-fronted (Cloudflare for 3, CloudFront for Jupiter), so the latency gap is edge-caching policy, not hosting region. Update the methodology disclosure, the per-provider finding, and the FAQ entry to reflect what the data actually shows. * bench-029: warm-path harness + remove false 'Mobula operates' claim Two coupled changes: 1. Harness switches from cold-path (KeepAlive: -1, DisableKeepAlives: true, new http.Client per tick) to warm-path: one persistent http.Client per provider, TCP+TLS reused across the 60s tick, HTTP/2 attempted. The measured number is now steady-state RTT — what a long-running backend integration actually sees — not the first-call cold handshake penalty. Shared helper in cmd/monitor/http_client.go. 2. Methodology + FAQ updated to reflect warm-path. Drop the inaccurate 'Mobula operates OpenChainBench' disclosure (Mobula sponsors harnesses that it hosts on its Railway infra, but does NOT operate or own the project). Keep the CDN disclosure since DNS verification confirmed all 4 providers sit behind a CDN — that fact still affects how the numbers should be read. * alternatives: pad JSON-LD description to Schema.org Dataset 50-char minimum * fix: bust bench cache keys to surface bench-029 (#212) The all-benchmarks-v6 and bench-unfiltered-v4 entries cached before bench-029 (solana-dex-quote-latency) was merged were still serving from Vercel Data Cache, so the new bench was 404 on direct URL and absent from /benchmarks search. Bumping to v5 / v7 forces a fresh build. * fix(brand): distinct provider colors + sgp region glyph (#213) - Jupiter switched from #FF7A00 (amber-orange) to #C7F284 (jupiter matrix green, their secondary brand) so it doesn't collide with mobula's vivid orange on the solana-dex-quote-latency by-region chart. - Add raydium (#00C2FF cyan) and openocean (#2563EB deep blue) so they stop falling through to the editorial palette and reading as similar orange/violet on the chart. - Add sgp brand color + isRegion entry alias to ap-southeast. The bench dimension value is the raw Railway label sgp, but the brand lookup was only registered under ap-southeast, so the SGP region tab had no globe glyph and no colored ring (rpc-capabilities had the same issue). - Rename the SGP region label from 'AP-Southeast' to 'Singapore' on both bench-029 and rpc-capabilities. AP-Southeast is AWS naming; for end users Singapore is clearer. * fix(bench): hide chart's internal region tabs when bench declares dimensions.region (#214) When a bench YAML has dimensions.region, the top-level Region row already controls region scoping. The TimeSeriesChart was still rendering its own internal region tabs because the page only forwarded the region prop when showChartRegionRow was true (the chart-only fallback path). Now we pass the top-level region selection down whenever regionOptions exist, so the chart hides its tabs and matches what the page-level filter selects. Also rename the AP-Southeast chart-tab label to 'Singapore' to match the top-level row, and add an alias for the raw 'sgp' region key. * site-header: 3D logo on desktop, SVG on touch (#215) * mobile: reliable sticky header + soften dot pattern (#216) * feat: hyperliquid frontends quality bench № 030 (draft, SQLite cohort retention) * hl-frontends: COALESCE null retention sum + METRICS_ADDR local-dev override * mobile: fixed header + dynamic spacer, dots more visible * bench-029 v2: rotate USDC → long-tail trending tokens (#220) The anchored SOL → USDC pair Jupiter was serving from CloudFront edge cache in 35 ms — that measured cache, not routing. Rotating the tokenOut every tick from Solana volume ranks 6-55 (stables excluded) defeats any per-pair edge cache and forces every provider to actually search a path. Harness changes (mirrored to OCB public repo from mobula-api PR #6613): - trending.go (new): fetches the rotation list every 10 min, picks one token at random per tick. - Provider contract adds tokenOut parameter; all 4 adapters refactored to quote USDC → tokenOut at 100 USDC notional, 1% slippage. - New counter solana_quote_no_route_total excluded from the latency histogram (liquidity gap is not a latency outlier). - isNoRouteSignal matches Jupiter NO_ROUTES_FOUND / TOKEN_NOT_TRADABLE, Mobula 'No route found' / 'Token not found', Raydium INSUFFICIENT_LIQUIDITY / ROUTE_NOT_FOUND. OpenOcean is payload-detected (outAmount==0, dexId<0, path==null, price_impact==-100%) since it has no string error. - log_buffer ring is now O(1) instead of O(n) slice-shift. YAML changes: - methodology rewritten around the rotation, no-route counter, and warm-path keep-alive measurement. - per-provider formulas tightened to the verified response field name + the no-route handling. - FAQ adds entries for the rotation rationale, the no-route counter, and Raydium's single-venue scope. * mobile: opaque bg + GPU hint on fixed header (kill notch see-through) (#221) * hyperliquid-frontends: flip status to live (page renders, awaits real builders.json addresses for data) * site-logo-switcher: layer SVG under 3D to kill the swap flash * hl-frontends: shorten disclaimer + 2 methodology bullets to satisfy Zod 500-char cap * logo-switcher: never paint SVG on desktop (CSS-gate) * bench-029 v3: switch rotation source from REST volume-ranks to Pulse V2 WS Sacha's ask was 'pulse trending tokens', not 'top by 24h volume'. v2's REST volume-ranks pick was a related-but-different signal — established mid-tier tokens with cumulative volume, not what's pumping right now. v3 actually wires Pulse V2 in. Harness changes (mirrored from mobula-api PR): - New pulse_subscriber.go opens a persistent ws://pulse-v2-api.mobula.io session, subscribes to the bonded view on Solana, dedupes by mint into a 30-min sliding pool keyed by lastSeen. Reconnect with 5s -> 60s exponential backoff. ~50-300 active mints in steady state. - trending.go now exposes Note() / Forget() (called from the WS handler) and a Pick() that prefers the pulse pool over the REST snapshot. REST refresh kept on a 10-min cron as the fallback for Pulse outages. - scheduler.go boots both goroutines, waits up to 30s for either source to fill the pool, then ticks. The 'src=PULSE|REST' log per tick makes the active source observable in real time. - gorilla/websocket added to go.mod. YAML methodology rewritten to describe the Pulse rotation. Raydium's no-route rate disclosure bumped from ~30-50% to ~80% because the bonded view is mostly Pump.fun graduates living on PumpSwap, not on Raydium-native pools. Local validation: 6 ticks observed, 5/6 sourced from PULSE, 6 distinct tokens (FARTCOIN, tolytown, cbBTC, Electricia, DUCKY, TOPLESS). Jupiter no-routes on 1, OpenOcean on 1 (Electricia), Raydium on 5 (as expected). Pool grew 50 -> 61 over the run. * hl-frontends: real builder addresses (Phantom, Axiom, pvp.trade, Insilico, Defiapp, MetaMask, Dexari, Okto) * logo-switcher: instant sphere placeholder on desktop while 3D loads * add logos + registry entries for 8 hyperliquid frontends * logo: static import 3D, drop dynamic + placeholder * hl-frontends: registry entries + logo manifest mappings (dropped from earlier squash) * fix(l1-finality): switch Litecoin from Blockchair to litecoinspace.org (#233) Blockchair's free-tier IP-blacklisted Railway's egress; the Litecoin fetcher had been returning HTTP 430 'temporary blacklisted' on every tick (359 errors/h, l1_finality_health{chain=litecoin}=0, the last_refresh_timestamp gauge was never even emitted because no fetch ever succeeded). Switched to litecoinspace.org (mempool.space-style Esplora API for Litecoin) — free, no API key required, no IP blacklist. The new fetcher is one call per tick (GET /api/blocks returns 15 recent blocks with height + unix timestamp; the 12-confirmations finalized block is index 12 of the array) vs Blockchair's two-call /stats + /dashboards flow. Fallback to a second /api/blocks/<height> call if the confirmation depth ever exceeds the default window. Verified locally: lag=1912s (= 32 min, matches 12 confirmations × 2.5 min LTC block time), fetch latency 33ms warm-path. * hl-frontends: switch headline from rolling 24h to last complete UTC day * fix: mobile overflow on benchmark detail pages - Tables (ledger, region-grid): min-w only applies from sm: so mobile shrinks the table inside its overflow-x-auto wrapper instead of forcing 480/520px and pushing the whole page past the viewport edge - count-leaderboard summary strip stacks 1-col on mobile, reduces gap-x so 'RANGE 40 → 306 7 providers' no longer cuts off - summary-stat removes the 8rem min-width on mobile flex children - breadcrumb + bench identifier lower tracking on mobile, last crumb truncates at 60vw so long titles can't push the row off-screen - Title + subtitle get break-words as safety net - html { overflow-x: clip } as a final root-level safety net * fix: site-logo mask id collides between hidden header + visible footer useId() gives each instance its own mask id. The shared 'c-mask-header' id was being defined by both the header (display:none on desktop) and the footer; Safari ignores a mask whose owning SVG is hidden, so the footer painted an unmasked filled circle instead of the C cutout. * hl-frontends: walk back 3 days to handle Hyperliquid bucket publish lag * fix(l1-finality): slow Cardano polling to 60s to stay under Koios free-tier quota Cardano was DOWN for ~96h: l1_finality_health{chain=cardano}=0, 359 errors/h via api.koios.rest. Koios responds 200 from any clean IP, but the harness on Railway hits 429/403 — Koios's free tier is 5,000 req/day/IP, and our default 10s cadence × 2 calls per tick = 17,280 req/day, which burns the quota in ~7h then blacklists the egress IP. Add a per-chain MinIntervalSeconds field to ChainConfig: when > 0, the fetchAll dispatcher skips ticks for that chain until enough time has passed. Cardano set to 60s → 2,880 req/day, safely under the quota. Other chains keep the global 10s cadence (Solana/Tron/EVM via direct RPC have no such quota). Resolution loss is minimal: Cardano blocks are ~20s and 15 confirmations takes 5 min of wall-clock to finalize, so per-minute polling is plenty. Verified locally: 2 Cardano ticks in 75s (~60s cadence), lag=478-513s (= 15 confs × ~20s + empty-slot variance), other chains still 8 ticks in 75s. * fix(l2-block-time): head-watchdog to catch ghost WS connections Scroll row went silent: samples_total stuck at 23,887 for >1h, 0 reconnects in the same period, health=1. The reconnect loop never fires because the pong handler bumps SetReadDeadline on every pong, so a provider that keeps the WS heartbeat alive while silently dropping our newHeads subscription wedges the read loop forever. Scroll already had 109 lifetime reconnects (vs 12-37 on the other 8 L2s), confirming the publicnode → Scroll WS link is the flakiest of the bunch. Add a per-connection head-watchdog goroutine: stores the timestamp of the last received newHead in an atomic int64, checks every 15s, and forces a conn.Close() if the silence exceeds 90s. Closing the conn makes ReadMessage return → runChain returns → outer reconnect loop bumps the counter and dials again. All 9 L2s have block-time under 15s, so 90s of silence is unambiguously a dead subscription. Verified locally: 6 Scroll blocks in 30s plus all 8 other L2s emitting at their natural cadences; watchdog stays quiet under normal traffic. * timeseries: interpolate hover-dot Y between sample brackets so it sits on the line The hover dot was using Math.round to snap to the nearest sample's value, which disagreed visually with the line during steep slopes. The line is drawn as straight SVG segments between samples (M…L…L…), so at hover-X between sample N and N+1 the line is at lerp(v[N], v[N+1], t) — but the dot was at either v[N] or v[N+1] depending on which way Math.round broke. Most visible on bench-029 when a provider's quote goes from 1.5s -> 200ms between two consecutive samples: dot floats above or below the descending segment. Fix: floor instead of round to get the bracketing pair, lerp the value at the fractional position. Dot now lands exactly on the rendered line. The skip-rendering rule is preserved (no dot when either bracket is a gap). * fix: stronger mobile overflow safety net for Safari + Telegram WebView - overflow-x: clip on html AND body (WKWebView ignores it on html alone) - max-width: 100vw on body to harden against any wide grandchild - break-words on SEO intro + methodology paragraphs * fix: shrink region-grid + hide extra ledger cols on mobile aggregator-head-lag (and other geo-distributed Solana benches) showed overflow on iPhone because: - region-grid cells used grid-cols-[1fr_5rem] on every viewport; with 3 regions + a 128px Product column the table intrinsic min was ~404px > 375px iPhone SE viewport. Now: grid-cols-[1fr_auto] on mobile, Product col w-20 (80px) instead of w-32, tighter gap, smaller value font. Stays at the original densities from sm: up. - ledger Slot delta and Secondary columns were visible on mobile (no hidden md:table-cell), adding 2 extra mono columns to benches with hasSlots/secondary. Now hidden on mobile like the other aggregate columns (p90/p99/Mean/Delta/Success). * fix: defensive overflow-x: clip on main + article (mobile WebView) The html/body-level clip from PR #242 is sometimes ignored by iOS WKWebView (Telegram in-app browser, certain Safari versions) when a deep descendant forces width > viewport. Adding the clip on main and article means the guard applies on a non-root box where browsers honour it consistently. Also w-full + min-w-0 + max-w-full at each level so flex/grid children can actually shrink instead of inheriting an intrinsic min-content width. LiveIndicator: drop the 'updated Ns ago' suffix on <sm screens. The inline-flex container can't wrap internally, so on a 375 px viewport the whole bench-identifier row was pushed beyond the edge. Dot + Live keeps the freshness signal without the width tax. * fix(solana-quote): align region key to sgp so SGP tab renders (#245) * chore: remove em-dashes / en-dashes from user-facing content (#246) User wants prose to read less AI-generated. Replaced all em-dashes (—) and en-dashes (–) in rendered content with commas, periods, colons or parentheses depending on context. Comment-only dashes (JSDoc, //, /* */) kept as-is since they never reach the rendered site. Files touched: 5 YAML benchmark specs, 1 markdown bench page, provider registry descriptions, OG / Twitter image alt text, alternatives SEO description, MCP API param description, donut-chart placeholder. Mobula affiliation audit: clean. The site already reads as a neutral third-party benchmark project. Mobula appears only as a benchmarked provider alongside Codex, GeckoTerminal, etc., never as parent / owner. * feat(hl-frontends): cutover to v2 (sub-minute freshness from local hl-node) (#247) Swap p50/p99/sample_size/series/mean queries to the *_v2 metrics emitted by the new hyperliquid-frontends-local harness on OVH SGP. Volume/fees/ effective_fee_bps/fills/fees_per_user now refresh every 30s instead of the 24-48h bucket lag. Keep p90 = hl_frontend_d7_retention_pct{} from v1 since v2 doesn't have SQLite retention state yet (deferred to v2.1). Replace the success metric (csv_fetch_status was specific to the bucket fetcher; v2 has no HTTP layer) with a harness-liveness heartbeat: hl_frontend_local_last_tick_unix_v2 must be < 120s old. Note: the v2 rolling 24h window started filling at 14:21 UTC today, so volumes will look low for the next ~22h until the window is fully populated. After that they'll match v1's full-day numbers. * feat(hl-frontends): refresh findings + FAQ with v2 bangers (deviation, outage, taker, per-asset) (#248) Replace the old findings block (template-based, generic) with 6 new findings that cite specific live numbers from the v2 metrics: - effective fee spread 10x across cohort (Insilico 1.00 → MetaMask 10.00) - price deviation as user-cost signal (Dexari 25.83 bps, 13x median) - volume ≠ user-friendliness (Phantom #1 volume but Insilico cheapest) - two live outage signals (Dexari 19min stale, Okto 24h inactive) - taker share separates aggressive UIs from pro terminals - asset concentration reveals routing strategy (HIP-3 stocks on Dexari/MM) Plus 2 new FAQ entries explaining what price_deviation and last_fill_age actually measure, since they are unique to this bench in the HL space. * feat(bench): switchable companion metric panels under main ledger (#249) Spec authors can now declare any number of `metric_panels` (up to 8) in a bench YAML. Each panel names one Prometheus metric; the loader queries it per provider as `<metric>{<label_key>="<slug>"}` and stores the scalar values. A new MetricPanelGrid component renders below the main table with a tab row at the top; clicking a tab re-ranks the cohort by that panel's metric, applying its higher_is_better direction. Hyperliquid frontends ships first with 5 panels: slippage proxy, time since last fill (outage), fills per minute (activity), taker share, and volume routed. All values come from the local hl-node v2 harness at sub-minute freshness. Non-breaking: benches without `metric_panels` render exactly as before. Cardinality impact on Prom is bounded (5 metrics x 8 providers = 40 new series across all panels for this bench). * feat(bench): metric view tabs swap the main chart instead of stacking tables (#250) Previous PR rendered each metric panel as a separate static table under the main chart. This commit replaces that with a tab row above the chart itself: clicking a tab swaps which metric the chart displays (per-builder multi-line, 24h trailing) so the reader stays in a single visual context and can compare trends instead of snapshots. Implementation: - spec.ts now fetches a 24h series for each panel x provider alongside the scalar values (`prom.series(query, winSec, 72)`). - MetricPanel gains an optional `seriesByProvider` field. - TimeSeriesChart accepts new optional props (seriesOverride, metricLabelOverride, unitOverride) that swap its data source + header. - New MetricViewTabs component pure presentation; parent owns active id. - benchmark-body wires the state, panel description rendered under chart. - Drops the old MetricPanelGrid table component (no longer used). Non breaking: benches without `metric_panels` render exactly as before (the tab row is conditionally rendered only when panels are declared). * fix(chart): include metric panel id in seriesKey so chart remounts on tab swap (#251) The Chart child component used `key={seriesKey}` where seriesKey only encoded range + region. When the parent swapped seriesOverride via a metric panel tab click, lines useMemo did recompute (override added to deps) but the Chart child's internal state (drawn paths, slicedLines) kept the prior render cached. Adding the active panel label to the key forces a clean remount on tab swap. * docs(hl-frontends): refresh disclaimer, seo_intro, abstract, methodology, faq for v2 pipeline (#252) Rewrites the bench documentation to describe the local hl-node fills stream rather than the deprecated daily CSV bucket. The methodology list now covers all 9 metrics actually emitted (effective fee, fees per user, price deviation, last fill age, fills per minute, taker share, per asset top three, plus the heartbeat and rolling window mechanics). FAQ entries that referenced the CSV URL or the old frontend list (OKX, GMGN, Dexterity, KGEN, ASXN) updated to match the current builders.json (Phantom, Axiom, pvp.trade, Insilico, DefiApp, MetaMask, Dexari, Okto). Source URL points at the new miniapps/hyperliquid-frontends-local harness. No em dashes anywhere in prose per content guideline. All character caps respected. * chore: safe cleanup — dead code, stale lint suppressions, unused vars (#253) Audit-driven cleanup verified to have zero visible impact on the site. Deletes (confirmed zero imports across src/): - src/components/benchmark-table.tsx (201 LOC, never imported) - src/lib/methodology-tooltip.ts (37 LOC, helper never wired up) - src/lib/utils.ts (cn() helper, never called) - CURRENT_ISSUE and ISSUE_DATE_LONG exports in site.ts Lint cleanups: - Drop three stale eslint-disable directives (apple-icon, icon, benchmarks/[slug]/page) for rules that no longer fire - Fix two unescaped apostrophes in mcp/page.tsx - Remove unused activeTemplate variable in share-section.tsx - Drop padB / padR from a useMemo deps array in time-series-chart.tsx where they were never read in the closure Kept on purpose despite audit suggestions: - .card CSS class (used 5x by card / live / benchmark-grid / body) - --color-accent-soft and --color-banner-* tokens (used by Tailwind utilities in benchmark-grid, view-switcher, home-bench-table, site-banner) - GithubIcon custom SVG (lucide-react dropped its GitHub brand mark) - Static three.js import on the 3D logo (replacing with next/dynamic re-introduces the SVG flash that PR #231 deliberately removed) - Hook-rule and ref-during-render lints in benchmark-body / time-series-chart: real refactors, out of scope for a zero-impact cleanup, deferred to a follow-up Verified: pnpm typecheck clean, pnpm build green. * hl-frontends: expand providers 8 → 60 (DefiLlama registry sync) (#254) * chore: remove SiteBanner (#256) User reports the banner leaves a phantom gap in Telegram WebView even once dismissed (localStorage doesn't always persist in in-app browsers, and the wrapper's padding still measures even when the banner returns null). Drop the component, drop the import, drop the spacer's banner- related comment + the now-irrelevant initial height estimate. * chart: add Top 5/10/20/All selector when cohort > 10 providers (#257) * chart: size top-N selector off registered cohort, not emitted series (#258) * chore: delete verified dead code (real pass, not agent guesses) (#255) This time I greped every file myself instead of trusting agents. Deletes (zero usages confirmed across src/): - src/components/live-dashboard.tsx — 6-line re-export shim; inlined the import in app/page.tsx so the shim has nothing left to redirect. - src/lib/brand.ts: brandIsDark() — never called anywhere. - src/lib/live/format.ts: fmtAge() — never called. live-indicator.tsx has its own formatAge() with day handling that fmtAge lacks, so they weren't an actual duplicate either. - src/components/chain-coverage-chip.tsx: leaderChains(), perChainRankSegments(), chainLabelFor(), chainLeader() — exported but never imported AND never called from within the same file. TypeScript noUnusedLocals confirms they were stranded. Visibility tightened: - src/lib/brand.ts: isLight() now non-exported (only used by chipTextColor in the same file). Drive-by cleanup that fell out: - Dropped ProviderResult import in chain-coverage-chip.tsx (only the deleted helpers used it). Verified clean: pnpm typecheck + pnpm tsc --noEmit --noUnusedLocals --noUnusedParameters + pnpm build all pass. Net: -79 lines. * fix: drop overflow-x clip from body so position: fixed sticks on WKWebView (#259) User reports a 1-2 px gap at the top of the header in Telegram in-app Safari that lets page content peek through during scroll. Root cause: body had overflow-x: clip (PR #242 safety net) and WebKit treats clip on body as creating a containing block for position: fixed descendants. The fixed header was then anchored to body's top edge, not the viewport, and momentum scroll desynced it from the URL-bar animation, producing the visible slit. Move the overflow-x clip safety net to <html> only. <main> and <article> already carry overflow-x-clip (PR #244) so page-level horizontal overflow remains contained without needing it on body. Drop the translate3d hint on the header — it was paired with the clip workaround and is not needed once fixed positions cleanly against the viewport. * hl-frontends: 60-builder registry, real logos, methodology refresh, availability backstop (#260) * hotfix(hl-frontends): trim methodology line 8 to <=500 chars (Zod limit) (#261) * fix: switch header to position: sticky (iOS 26 WebKit fixed jitter bug) (#262) Web search confirmed user is not alone. WebKit bug 297779 reports that on iOS 26 Safari + WKWebView (Telegram in-app browser, Chrome iOS), any position: fixed element drifts 10-24 px when scroll direction reverses, exposing the page under the header through a thin gap. Mastodon and LinkedIn ship the same symptom. Apple acknowledged the bug, partially fixed it in iOS 26.1 but residual reports persist into Dec 2025. position: sticky uses a different render path in WebKit that does not trip the jitter heuristic. It is also simpler: - no spacer div needed (the element stays in normal flow) - no useRef + ResizeObserver to keep the spacer in sync - no headerH state Net: -25 lines on top of fixing the cosmetic gap. Desktop and standalone Safari behavior are identical, sticky just renders more reliably in WebViews. Refs: - https://bugs.webkit.org/show_bug.cgi?id=297779 - https://github.com/mastodon/mastodon/issues/36144 - https://github.com/TelegramMessenger/Telegram-iOS/issues/1748 * fix: drop rows with zero headline value from ledger and ranked-bar (#264) * fix: switch header to position: sticky (iOS 26 WebKit fixed jitter bug) Web search confirmed user is not alone. WebKit bug 297779 reports that on iOS 26 Safari + WKWebView (Telegram in-app browser, Chrome iOS), any position: fixed element drifts 10-24 px when scroll direction reverses, exposing the page under the header through a thin gap. Mastodon and LinkedIn ship the same symptom. Apple acknowledged the bug, partially fixed it in iOS 26.1 but residual reports persist into Dec 2025. position: sticky uses a different render path in WebKit that does not trip the jitter heuristic. It is also simpler: - no spacer div needed (the element stays in normal flow) - no useRef + ResizeObserver to keep the spacer in sync - no headerH state Net: -25 lines on top of fixing the cosmetic gap. Desktop and standalone Safari behavior are identical, sticky just renders more reliably in WebViews. Refs: - https://bugs.webkit.org/show_bug.cgi?id=297779 - https://github.com/mastodon/mastodon/issues/36144 - https://github.com/TelegramMessenger/Telegram-iOS/issues/1748 * ranked-bar+ledger: add Top-N selector and drop unscored rows * ledger+ranked-bar: drop rows with zero headline value across p50/p90/p99 * chart: slice seriesOverride by range so panel tabs respect 1h/6h (#265) * chart: Top-N buttons reflect available data (hide useless options) (#266) * fix: drop sticky on mobile (iOS 26 WebKit jitter workaround) (#267) position: sticky and position: fixed both jitter 10-24 px on iOS 26 when scroll direction changes (bugs.webkit.org/297779). Apple acknowledged the bug, partial fix in 26.1, residual reports into late 2025. Mastodon and LinkedIn ship the same symptom and concluded it is unfixable from the site side. Mitigation: keep sticky only on md+ where the desktop render path is clean. On mobile the header stays in normal flow, scrolls away with the page, and the jitter cannot appear because nothing is positioned. Same pattern as most mobile sites — pull to top to see the nav. Single class change: `sticky top-0` -> `relative md:sticky md:top-0`. * fix: keep sticky header on regular mobile, only drop it in iOS WebViews (#268) Previous PR (#267) dropped sticky on all mobile to dodge the iOS 26 WebKit jitter bug. Overkill — the symptom only manifests visibly inside in-app WebViews (Telegram, Instagram, Facebook, Twitter, TikTok) where the host's own URL-bar overlay animates on top of WebKit's misposition. Regular mobile Safari and Chrome iOS render sticky cleanly enough for end users. Mechanism: - Inline script in layout.tsx head detects iOS WebView via UA (no `Safari/` token on an iOS device = WKWebView in-app host) and adds the `ios-webview` class on <html> before React hydrates. - globals.css scopes a non-sticky override to that class on the site header at < md viewports. - site-header.tsx exposes a stable identifier class (`site-header-root`) and stays sticky-top-0 by default everywhere else. No flash on first paint because the class lands during the same render tick as the dark-mode detection script next to it. Build + typecheck clean. * fix(chart): Top-N selector on all 4 chart views (ranked, time, distribution, donut) (#269) * feat(bench): network fees — current native transfer cost USD across 11 L1 chains * chart: Top-N selector on all 4 chart views via shared hook+component * ledger: follow active panel — sort/filter/value per panel metric (#271) * wallet-labels-coverage: drop All chains aggregate tab (#272) * feat(bench): network fees — current native transfer cost USD across 11 L1 chains * chart: Top-N selector on all 4 chart views via shared hook+component * wallet-labels-coverage: drop All chains aggregate tab * feat(bench): network fees — current native transfer cost USD across 11 L1 chains (#270) * bench-body: pass activePanel to LedgerTable + dynamic ledger title (#273) * hotfix(bench-body): restore ledger title template literal + closing brace (#276) * feat(network-fees): L1/L2 toggle + 9 L2 providers (#278) * fix(network-fees): granular USD format + split L1/L2 ledgers (#279) * fix(network-fees): granular USD format + split L1/L2 ledgers * chore: gitignore agent worktrees * sync(topN): lift Top-N to parent so chart and ledger share one value (#280) * chore: delete verified dead exports (audit rerun) (#274) Re-greped each export in src/lib against the rest of src/. Deletes (0 references confirmed): - leaderForChain() in citation.ts — defined when per-chain headline rewrites were planned, never wired up. - fmtCount() in live/format.ts — fmtCountFull is what ticker.tsx uses; the short variant has no callers. Tightened visibility (only used inside the defining file): - AlternativeWithBench (alternatives.ts) export -> internal type - stripFaqMarkdown (jsonld.ts) export -> internal helper - chainIdFragment (jsonld.ts) export -> internal helper pnpm exec tsc --noEmit --noUnusedLocals --noUnusedParameters: EXIT=0 pnpm build: green. * fix(network-fees): top layer toggle + drop top-N selector (#281) * sync(topN): lift Top-N to parent so chart and ledger share one value * fix(network-fees): top-of-page layer toggle, drop top-N selector * fix(bench-body): restore topNControl + ledger topN wiring (dropped in #281) (#282) * feat(network-fees): SEO rewrite, target user questions, no hyphens (#283) * sync(topN): lift Top-N to parent so chart and ledger share one value * feat(network-fees): SEO rewrite + scope clarification, drop hyphens * fix(ledger): rescale sparkline min/max off active panel series (#284) * sync(topN): lift Top-N to parent so chart and ledger share one value * feat(network-fees): SEO rewrite + scope clarification, drop hyphens * ledger: rescale sparkline min/max off active panel series * hl-frontends(seo): pro tone, brand names in description, drop stale OKX/GMGN mentions (#285) * hl-frontends(seo): feature FOMO in description + 2 new FAQ entries (#287) * fix(bench): bump maxDuration to 60s for 20+ provider benches (#286) * sync(topN): lift Top-N to parent so chart and ledger share one value * fix(bench): maxDuration 60s for cold start with 20+ providers * feat(hl-frontends): pivot headline to USD revenue + 7d/30d ranges (#289) * sync(topN): lift Top-N to parent so chart and ledger share one value * docs(network-fees): drop the chain count from subtitle + seo description User reads cleaner without the number; the list of chains right after it already says which 20 are tracked. * feat(hl-frontends): pivot headline to USD revenue, add 7d/30d ranges * fix(hl-frontends): collapse redundant fee/volume panels, use chart range tabs (#290) * sync(topN): lift Top-N to parent so chart and ledger share one value * fix(hl-frontends): collapse fees/volume time variants — use chart range tabs instead * fix(hl-frontends): mark volume and users panels as higher_is_better so ledger sorts descending (#291) * fix(build): remove leftover merge conflict markers (#292) * sync(topN): lift Top-N to parent so chart and ledger share one value * fix(build): remove leftover merge conflict markers in chart components * fix(build): really remove leftover merge conflict markers (#293) * fix(chart): anchor partial series on right edge, stop stretching across full range (#294) * fix(build): really remove leftover merge conflict markers * fix(chart): anchor partial series on right edge instead of stretching across full range * fix(hl-frontends): swap p90 metric to unlock 60-builder cohort (#295) p90 was hl_frontend_d7_retention_pct which only the legacy 8-builder Railway harness emits. spec.ts skips any provider with a null p90 or p99, so 52 of 60 builders (including the top earners Phantom Perps $83K, Rabby $15K, OneKey $12K, based.app $12K) were silently dropped from the leaderboard. Switching p90 to hl_frontend_volume_usd_24h_v2 (60 series) restores the full cohort. * fix(chart): y-axis tick formatter handles usd + count (#296) * fix(hl-frontends): swap p90 metric to unlock 60-builder cohort p90 was hl_frontend_d7_retention_pct which only the legacy 8-builder Railway harness emits. spec.ts skips any provider with a null p90 or p99, so 52 of 60 builders (including the top earners Phantom Perps $83K, Rabby $15K, OneKey $12K, based.app $12K) were silently dropped from the leaderboard. Switching p90 to hl_frontend_volume_usd_24h_v2 (60 series) restores the full cohort. * fix(chart): y-axis tick formatter now handles usd + count units * fix(chart): panels fetch 24h+7d+30d series so range tabs show real data span (#297) * fix(hl-frontends): swap p90 metric to unlock 60-builder cohort p90 was hl_frontend_d7_retention_pct which only the legacy 8-builder Railway harness emits. spec.ts skips any provider with a null p90 or p99, so 52 of 60 builders (including the top earners Phantom Perps $83K, Rabby $15K, OneKey $12K, based.app $12K) were silently dropped from the leaderboard. Switching p90 to hl_frontend_volume_usd_24h_v2 (60 series) restores the full cohort. * fix(chart): y-axis tick formatter now handles usd + count units * fix(chart): panels fetch 24h+7d+30d series so range tabs show right data span * fix(chart): Top N ranking respects higher_is_better, matching ledger (#298) * fix(hl-frontends): swap p90 metric to unlock 60-builder cohort p90 was hl_frontend_d7_retention_pct which only the legacy 8-builder Railway harness emits. spec.ts skips any provider with a null p90 or p99, so 52 of 60 builders (including the top earners Phantom Perps $83K, Rabby $15K, OneKey $12K, based.app $12K) were silently dropped from the leaderboard. Switching p90 to hl_frontend_volume_usd_24h_v2 (60 series) restores the full cohort. * fix(chart): y-axis tick formatter now handles usd + count units * fix(chart): panels fetch 24h+7d+30d series so range tabs show right data span * fix(chart): Top N ranking respects higher_is_better, matching ledger sort * feat(wallet-labels): kind dimension toggle (contract vs eoa) (#304) * docs(wallet-labels): explain EOA vs contract kind split in methodology + FAQ (#305) * feat(bench-032): add pm-data-freshness bench (Polymarket T0 vs Codex vs Mobula) (#308) * feat(logo): add Polymarket provider logo (#309) * chore(mcp): allowlist pm_ prefix for the pm-freshness bench (#310) * fix(pm-freshness): scrape via Railway internal DNS not public URL (#311) * fix(pm-freshness): sub-ms floor for Polymarket T0 row so it shows on the chart (#312) * chore(pm-freshness): short provider names + drop em dashes in editorial copy (#313) * fix(bench): Best/Worst headline respects higher_is_better (#314) For benches like HL frontends (higher_is_better=true, fees in USD), Best is the max value not the min, and Worst is the min not the max. Latency benches keep the original min=Best mapping. Fixes BEST=$0 / WORST=$24K on staging HL frontends page where the leaders (phantom-perps $119K, metamask $66K) were being mapped to Worst. * release: exclude held-back benches (hyperliquid-frontends, solana-dex-quote-latency, solana-tx-landing*, wallet-labels-coverage)
16 tasks
Flotapponnier
added a commit
that referenced
this pull request
Jun 24, 2026
- Linear-inspired chrome: hairline border, single soft shadow, no
accent ring, snappy 150ms open animation (fade + slide-down 4px).
- New useRecentSearches hook (localStorage, ocb:search:recent:v1
namespace, cap 7, custom-event same-tab sync, useSyncExternalStore
for hydration safety).
- Idle state surfaces 3 sections:
- Recent chips (horizontal scroll with edge fade, X to remove)
- Live leaders cards (horizontal snap, fetched from /api/citable,
shows current #1 provider + p50 value with logo)
- Trending list (vertical, same data shape as featured)
- Search state: results grouped by kind, each row gets the leader's
logo (benches) or entity logo (products/chains), or a kind-icon
fallback for compares/answers/alternatives/pages.
- Mobile: full-bleed sheet, kbd footer hidden, X close button.
- /api/citable fetched once on mount; 300s edge cache means usually
zero network hit. Renders gracefully without it (logo + title only,
no p50 / leader).
This was referenced Jun 24, 2026
This was referenced Jul 3, 2026
Flotapponnier
pushed a commit
that referenced
this pull request
Jul 5, 2026
Replaces the identical templated intro paragraph on both pages with
a data-driven prose summary derived from live measurements. Kills the
last piece of duplicate above-the-fold text so Bing sees genuinely
unique substantive content per URL.
/compare/[slug]:
Before: 'Side by side OpenChainBench measurements. Identical layout,
no editorial verdict...' (same on 247 pages)
After: 'Codex leads on 2 of 4 shared benchmarks, Mobula on 2. Codex
wins on aggregator-head-lag (128ms vs 195ms), wallet-labels-
coverage (42.8% vs 38.1%). Mobula wins on metadata-coverage
(96.9% vs 89.1%), network-coverage (80 vs 42 chains).'
/products/[slug]:
Before: 'Moralis performance benchmarks, live across 2 categories.
Reproducible measurements, open methodology.' (same on 104 pages)
After: 'Moralis is measured across 2 live OpenChainBench benchmarks,
with 1 #1 finish: NFT collection metadata (ranks #1, 96.9% p50),
Wallet labels coverage (ranks #2 of 5, 42.4% p50).'
Falls back to a neutral sentence when the provider's p50 data is
missing (cold ISR, harness restart) so cold pages don't render a lie.
7 tasks
3 tasks
Flotapponnier
added a commit
that referenced
this pull request
Jul 6, 2026
Symptom: alertmanager fires 'geckoterminal head_lag stale on bnb' every few hours across all 3 regions in both staging and prod (observed 2026-07-06). Root cause: the BNB pool configured across 3 harnesses uses PoolID '24' which points to WBNB/BUSD PancakeSwap V2 — near-idle since Binance stopped issuing BUSD in 2024. Staging-sgp logs show 2 gecko/bnb samples per 5000 log lines vs 7 for base and 1 for solana; the sub-5min sample cadence the head_lag rule expects is impossible on a dead pool. Fix: replace with PancakeSwap V3 USDT/WBNB 0.01% fee tier, currently the #1 BSC pool by volume ($130M+ 24h). Internal GT pool_id = 160787671 (derived from app.geckoterminal.com/api/p1/bsc/pools/0x172fcd41e0913e95784454622d1c3724f546f849). Applied to all 3 harnesses that hardcode the same list: - aggregator-head-lag (fires the alert) - aggregator-latency-benchmark - metadata-coverage Co-authored-by: Florent Tapponnier <contact@mobula.io>
Flotapponnier
added a commit
that referenced
this pull request
Jul 6, 2026
… (#921) Symptom: alertmanager fires 'geckoterminal head_lag stale on bnb' every few hours across all 3 regions in both staging and prod (observed 2026-07-06). Root cause: the BNB pool configured across 3 harnesses uses PoolID '24' which points to WBNB/BUSD PancakeSwap V2 — near-idle since Binance stopped issuing BUSD in 2024. Staging-sgp logs show 2 gecko/bnb samples per 5000 log lines vs 7 for base and 1 for solana; the sub-5min sample cadence the head_lag rule expects is impossible on a dead pool. Fix: replace with PancakeSwap V3 USDT/WBNB 0.01% fee tier, currently the #1 BSC pool by volume ($130M+ 24h). Internal GT pool_id = 160787671 (derived from app.geckoterminal.com/api/p1/bsc/pools/0x172fcd41e0913e95784454622d1c3724f546f849). Applied to all 3 harnesses that hardcode the same list: - aggregator-head-lag (fires the alert) - aggregator-latency-benchmark - metadata-coverage Co-authored-by: Florent Tapponnier <contact@mobula.io>
5 tasks
Flotapponnier
pushed a commit
that referenced
this pull request
Jul 15, 2026
Two bench-correctness fixes surfaced by an audit of the oracle and gas surfaces we plan to cite in outreach. Bench 025 (oracle-deviation): - sample_size was 'count(count by (source) (ocb_oracle_price))' which returns the count of distinct sources (3 or 4), not the count of samples over the window. The citable API therefore reported sampleSize=3-4 on every pair, at odds with the spec's 'polled every 30s' claim. Rewrites the query to 'sum(count_over_time(ocb_oracle_price[24h]))' across all 10 pair entries so the number reflects actual poll volume. Leader ranking: - citationCandidates() now excludes providers with a measured success rate below 50 percent from the leader pool. Before this fix, gas-estimation showed Owlracle as #1 with successRate=6.48 and a p50 gap of 0.001 gwei: technically accurate on the rare successful poll but useless as a leader claim. Falls back to the full live pool when every provider is under the floor so a totally degraded bench still surfaces a best-of-bad-options leader rather than vanishing. Freshness benches without a success query default to 100 percent in the loader so the guard is inert there. Adds src/lib/citation.test.ts with 5 tests covering the Owlracle scenario, higher-is-better ranking, and the degraded-pool fallback.
Flotapponnier
added a commit
that referenced
this pull request
Jul 15, 2026
* fix(bench): sample-size PromQL + leader reliability floor Two bench-correctness fixes surfaced by an audit of the oracle and gas surfaces we plan to cite in outreach. Bench 025 (oracle-deviation): - sample_size was 'count(count by (source) (ocb_oracle_price))' which returns the count of distinct sources (3 or 4), not the count of samples over the window. The citable API therefore reported sampleSize=3-4 on every pair, at odds with the spec's 'polled every 30s' claim. Rewrites the query to 'sum(count_over_time(ocb_oracle_price[24h]))' across all 10 pair entries so the number reflects actual poll volume. Leader ranking: - citationCandidates() now excludes providers with a measured success rate below 50 percent from the leader pool. Before this fix, gas-estimation showed Owlracle as #1 with successRate=6.48 and a p50 gap of 0.001 gwei: technically accurate on the rare successful poll but useless as a leader claim. Falls back to the full live pool when every provider is under the floor so a totally degraded bench still surfaces a best-of-bad-options leader rather than vanishing. Freshness benches without a success query default to 100 percent in the loader so the guard is inert there. Adds src/lib/citation.test.ts with 5 tests covering the Owlracle scenario, higher-is-better ranking, and the degraded-pool fallback. * fix(bench): propagate reliability floor to rankings + template + hub Follow-up on the previous commit. Review flagged that filtering the leader without filtering the ranking arrays leaves self-contradictory JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle on the same document. Exports citationCandidates and adds rankedCandidates helper that returns the same eligible pool sorted by the bench's higher_is_better direction. leader() and fieldValue() now share it. Wires the shared helper into every surface that emits a rankings-like list or a best-of claim: - src/app/api/stat/[slug]: rankings array now sorted from rankedCandidates so rankings[0].slug always equals leader.slug on the same JSON. - src/app/api/llm-context: the Rankings numbered list under each bench matches the Headline sentence above it. - src/app/api/mcp/[transport]: get_benchmark tool response and the openchainbench://benchmark/{slug} resource both share the pool. - src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}} tokens in bench copy resolve against the same pool so a bench body never crowns a provider its own headline excludes. Per-slug tokens {{p50:some-slug}} still resolve against the raw live pool so unknown tokens still fall through. - src/lib/providers: rankProviders (drives /products hub wins count and per-chain leadership chips) now runs on the reliable pool. Adds a consistency test locking that rankedCandidates[0].slug always matches leader().slug on the same bench. Total: 6 tests in citation.test.ts, 95/95 pass across src/. Uses (r.successRate ?? 100) for the numeric guard to future-proof against ProviderResult shapes that skip the field. Load path still defaults to 100 (materialize/load.ts:748) so this is inert today, but the extra ?? removes the fragility. /api/compare/[a]/[b] is intentionally left unfiltered: the user picks the two providers explicitly, so the head-to-head is a requested comparison rather than a leader claim. Applying the floor there would drop rows the user explicitly asked to see. * fix(hub-card): honor citation leader in the hub grid headline Follow-up review flagged that benchmark-card.tsx re-sorted b.results locally and picked results[0], so a bench where citation demotes the raw-p50 winner (e.g. Owlracle on gas-estimation) would show the demoted provider on the hub grid while the bench page names the correct leader. Two documents contradicting each other in one click. Adds leaderSlug: string | null to BenchmarkCardData and populates it at the projection boundary via leader(b), which already applies the reliability + insufficient-sample filter. The card picks the leader by matching that slug in the projected results, and falls back to the raw best when the projection returned no leader (draft, insufficient, or all providers filtered out) so a card still renders a headline value in every state. Kept the local sort so the fallback path and any downstream chip using sorted[0].name keeps working. Test suite unchanged: 95/95 pass across src/. No test needed since this is a projection-boundary wire-up. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io>
Flotapponnier
added a commit
that referenced
this pull request
Jul 16, 2026
) * fix(bench): sample-size PromQL + leader reliability floor Two bench-correctness fixes surfaced by an audit of the oracle and gas surfaces we plan to cite in outreach. Bench 025 (oracle-deviation): - sample_size was 'count(count by (source) (ocb_oracle_price))' which returns the count of distinct sources (3 or 4), not the count of samples over the window. The citable API therefore reported sampleSize=3-4 on every pair, at odds with the spec's 'polled every 30s' claim. Rewrites the query to 'sum(count_over_time(ocb_oracle_price[24h]))' across all 10 pair entries so the number reflects actual poll volume. Leader ranking: - citationCandidates() now excludes providers with a measured success rate below 50 percent from the leader pool. Before this fix, gas-estimation showed Owlracle as #1 with successRate=6.48 and a p50 gap of 0.001 gwei: technically accurate on the rare successful poll but useless as a leader claim. Falls back to the full live pool when every provider is under the floor so a totally degraded bench still surfaces a best-of-bad-options leader rather than vanishing. Freshness benches without a success query default to 100 percent in the loader so the guard is inert there. Adds src/lib/citation.test.ts with 5 tests covering the Owlracle scenario, higher-is-better ranking, and the degraded-pool fallback. * fix(bench): propagate reliability floor to rankings + template + hub Follow-up on the previous commit. Review flagged that filtering the leader without filtering the ranking arrays leaves self-contradictory JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle on the same document. Exports citationCandidates and adds rankedCandidates helper that returns the same eligible pool sorted by the bench's higher_is_better direction. leader() and fieldValue() now share it. Wires the shared helper into every surface that emits a rankings-like list or a best-of claim: - src/app/api/stat/[slug]: rankings array now sorted from rankedCandidates so rankings[0].slug always equals leader.slug on the same JSON. - src/app/api/llm-context: the Rankings numbered list under each bench matches the Headline sentence above it. - src/app/api/mcp/[transport]: get_benchmark tool response and the openchainbench://benchmark/{slug} resource both share the pool. - src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}} tokens in bench copy resolve against the same pool so a bench body never crowns a provider its own headline excludes. Per-slug tokens {{p50:some-slug}} still resolve against the raw live pool so unknown tokens still fall through. - src/lib/providers: rankProviders (drives /products hub wins count and per-chain leadership chips) now runs on the reliable pool. Adds a consistency test locking that rankedCandidates[0].slug always matches leader().slug on the same bench. Total: 6 tests in citation.test.ts, 95/95 pass across src/. Uses (r.successRate ?? 100) for the numeric guard to future-proof against ProviderResult shapes that skip the field. Load path still defaults to 100 (materialize/load.ts:748) so this is inert today, but the extra ?? removes the fragility. /api/compare/[a]/[b] is intentionally left unfiltered: the user picks the two providers explicitly, so the head-to-head is a requested comparison rather than a leader claim. Applying the floor there would drop rows the user explicitly asked to see. * fix(hub-card): honor citation leader in the hub grid headline Follow-up review flagged that benchmark-card.tsx re-sorted b.results locally and picked results[0], so a bench where citation demotes the raw-p50 winner (e.g. Owlracle on gas-estimation) would show the demoted provider on the hub grid while the bench page names the correct leader. Two documents contradicting each other in one click. Adds leaderSlug: string | null to BenchmarkCardData and populates it at the projection boundary via leader(b), which already applies the reliability + insufficient-sample filter. The card picks the leader by matching that slug in the projected results, and falls back to the raw best when the projection returned no leader (draft, insufficient, or all providers filtered out) so a card still renders a headline value in every state. Kept the local sort so the fallback path and any downstream chip using sorted[0].name keeps working. Test suite unchanged: 95/95 pass across src/. No test needed since this is a projection-boundary wire-up. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io>
7 tasks
Flotapponnier
added a commit
that referenced
this pull request
Jul 25, 2026
* solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries
* nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* nft harness: delist rarible, free tier is 100 requests per month (#1137)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees
* bridge-monitor: on-chain balance fallback + degraded flag
* bridge-monitor: self-healing metrics + slack notifiers
* bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge
* bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight
* bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer
* bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default
* bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset
* bridge-monitor: keyless dry-run support for reaper + RunDryRun
* bridge-monitor: comment style cleanup in pre-flight docs
* bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive
DailySpentUSD only ever incremented and was zeroed by every restart, so a
crash-loop minted a fresh budget each time. The tracker resets exactly at
UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default
./spend-state.json, not /tmp) and is mutex-guarded. Also declares the
package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD
(default 0.50) helper for gas bled by failed broadcasts.
* bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock
Fund-loss fixes from hostile review:
- a corrective transfer whose deposit broadcast but whose bridge status
never resolved is now TERMINAL for the scheduler slot (funds are likely
still in flight, a retry double-sends); only pre-broadcast failures may
consume another attempt. executeOnBridge keeps TxHash on error paths so
callers can tell the two apart.
- ONE corrective-transfer budget (max 2) per scheduler slot, shared across
all downgrade-ladder rungs (was per rung: up to 6 transfers per slot).
- package-level execution mutex: scheduler slots own the wallets end to
end, the reaper TryLocks and skips its tick instead of queueing.
- broadcasts that failed or timed out after getting a TxHash book a flat
FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding
unaccounted gas; all spend goes through the persisted UTC-day tracker.
- gas top-up now honors MaxDailySpendUSD before broadcasting.
* bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode
The reaper (and any other consumer) used to accept simulated balances even
in production with broadcast-capable keys, so phantom numbers could
green-light real transfers. SimulateBalances now takes the execution mode
and returns nil outside dry-run; startup logs a warning when the flag is
set in a broadcast-capable mode.
* tx executor: preserve hash on ambiguous send errors, split dry-run spend state
* executor: add missing chain label, first real single-test panicked post-fill (#1141)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated)
* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tsp: parse the flat spark shape (close/timestamp arrays), log empty parses
* tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes
* rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages
* rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family
* rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions
* registry: googl links to google.com, abc.xyz 403s aggressively
* cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves
* fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152)
* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident)
* bridge-monitor: sub-50ms buckets + nanosecond precision on Observe
Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000]
to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents
on solver-cached corridors (HyperCore in particular) returns via a
1Click coordinator fast path in <50ms, and the previous scheme lumped
every one of those observations into (0, 50] so histogram_quantile
linearly interpolated to ~25ms regardless of true value. The p50 shown
on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a
bucket floor, not a measurement. Sub-50 buckets let the real bimodality
surface.
Also swap Observe(float64(quoteLatency.Milliseconds())) for
Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6
per-bridge observers (across, debridge, lifi, mobula, nearintents,
relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0,
same bug class as the RPC harness fix in PR #1128. Not a hot path here
(network RTT dominates), but keeps precision consistent with the new
low buckets and cheap to fix while we are already touching each file.
Bench YAML methodology + FAQ updated to reflect the new bucket set.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154)
* fix: 3 machine-readable surface bugs surfaced by GEO audit
1. /api/citable/{date} unit scaling.
Snapshot route emitted `value: 627` for `aggregator-head-lag` while the
live /api/citable emitted `value: 0.63` on the same measurement. The
live route already wraps with valueInDeclaredUnit(raw, b.unit) but the
snapshot route was left with the raw internal-ms value. Wrap the same
way; sub-1 s benches now render in the declared `s` unit on both routes.
2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=.
getBenchmark accepted a filters object but the route never parsed the
URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the
cross-chain aggregate. Parse the four dimension params, pass through
to the loader; unknown values fall back to unfiltered inside the
loader. Also widen getBenchmark options type to include kind and
venue — the underlying loadBenchmark already supports both.
3. Draft benches leaked a fake freshness signal.
Draft placeholder set lastRunAt to new Date for type safety
(Benchmark.lastRunAt is non-null), and both citable routes passed it
straight through as asOf. LLM crawlers treating asOf as ground truth
would think a draft bench was measured every minute. Null asOf in the
JSON when status is draft; the internal type stays intact.
* fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified
The GEO audit review found the initial fix in commit f6f84b8 only covered
the two /api/citable JSON surfaces, but the same "draft benches spoof
freshness" class of bug lives on three more machine-readable channels
that LLM crawlers also key on:
- /api/stat/{slug} asOf field.
- MCP tool responses (list_benchmarks, get_benchmark, resource template,
and the plain-text "Last sample" line).
- JSON-LD dateModified on the bench page, per-chain sub-page, answer
page and alternatives page — Google, Bing, Perplexity all consume
dateModified as a freshness ranking signal.
Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every
call site. Draft benches now uniformly omit or null the freshness
timestamp; live benches unchanged. On the visible /answers page,
the "Data as of..." line and its <time dateTime> anchor also hide for
drafts rather than displaying the wall-clock placeholder.
Follow-up to PR #1154 (commit f6f84b8) covering the surfaces the review
agent flagged as still spoofing.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* feat: /api/compare/{a}/{b} + openapi.json coverage (Wave 1 P0/P1) (#1156)
* feat: /api/compare/{a}/{b} + openapi.json coverage
Two GEO surfaces flagged by the audit as high-value gaps.
## /api/compare/{a}/{b}
Head-to-head comparison as a single citable payload. LLMs asked "Mobula
vs Codex head lag" would otherwise need two /api/stat calls plus their
own delta reasoning; this endpoint returns one flat structure with per
benchmark p50 for each side, the winner on the metric's
higher-is-better convention, and the absolute delta.
The intersection logic mirrors src/lib/related-providers.ts and the
HTML /compare/{slug} page: shared benchmark = both providers appear in
it. Intentionally returns flat leader-p50 only (no chain/region
breakdowns, no cache-heavy fan out) so the agent tool call stays fast.
## openapi.json coverage
Added /api/citable/{date} (was missing despite being a real API
endpoint) and /api/compare/{a}/{b} to the OpenAPI 3.1 spec. Also
documented the dimension query params (chain/region/kind/venue) on
/api/stat/{slug} that shipped in the previous PR but were undocumented.
Added Compare schema; extended Stat schema with `filters` field.
/llms.txt and /llms-full.txt stay outside the OpenAPI surface — they
are text files not RPC endpoints, and OpenAPI consumers do not handle
plain-text specs well anyway.
## citableAsOf helper widened
Type signature relaxed from full Benchmark to
`Pick<Benchmark, "status" | "lastRunAt">` so the slim
ProviderAppearance.benchmark used by the new compare route can pass
through without a downcast. No behavior change on existing callers.
## Notes on items NOT shipped in this batch
- Cache-Control on /llms.txt / /api/citable / /api/llm-context: the
audit report showed `cache-control: public` and inferred no CDN
caching, but the source already sets
`public, s-maxage=300, stale-while-revalidate=900`. Vercel strips the
`s-maxage` directive from the client-visible header while honoring it
at the edge; `x-vercel-cache: HIT` with `age: 256` confirms the CDN
cache is active. Not a real bug.
- .bib / .ris file MIME endpoints: string versions already ship inside
the /api/stat JSON via cite.bibtex / cite.apa. Marginal upside for
Zotero-style one-click imports; downgraded to P2.
- /api/stat/{slug}/sparkline.svg: sparkline data already ships as a
float array in /api/stat and the OG image at /api/og/{slug} covers
the "embed a chart in an answer" case. P2.
- JSON-LD `Dataset.variableMeasured.value`: current schema emits bare
strings; upgrading to PropertyValue objects would duplicate the
measured value already carried by the StatisticalReport node's
Observation.measuredValue. P2.
* openapi: add totalWins to Compare schema
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* feat: Wave 1 P2 batch — cite MIME, sparkline SVG, Dataset PropertyValue (#1158)
* feat: Wave 1 P2 batch — .bib/.ris/apa MIME, sparkline SVG, Dataset PropertyValue
Three GEO surface additions the audit downgraded to P2. Each one is
duplicative with an existing channel (cite.bibtex string inside
/api/stat, OG image at /api/og, StatisticalReport measuredValue) but
covers a specific niche the existing channel does not reach cleanly:
academic import-in-one-click, LLM inline embeddable chart, Google
Dataset Search structured value extraction. Ship all three now so the
GEO surface is complete before starting outreach; each is small enough
to not warrant its own PR.
## /api/cite/{slug}/{format}
Per-bench citation with the correct MIME type. Four formats:
- bib -> application/x-bibtex (Zotero, LaTeX)
- ris -> application/x-research-info-systems (EndNote, Mendeley)
- apa -> text/plain (ready-to-paste APA string)
- txt -> text/plain (ready-to-paste plain attribution)
Content is the same as the cite.* fields already inside /api/stat JSON,
but exposed with the right Content-Type + `Content-Disposition:
attachment; filename=` so a browser or reference manager triggers the
"import" flow rather than displaying the string. Added RIS to CiteBundle
in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2
/ ER fields, CRLF line endings per RFC convention).
## /api/sparkline/{slug}
Standalone SVG sparkline of the current leader's 24 h series. Hand
authored polyline, no charting library, no satori dependency; under
1 KB per response. Query params: `w` (40..1200, default 240), `h`
(20..400, default 60), `theme=light|dark`. Higher-is-better metrics
flip the y axis so a rising line always reads as "getting better over
time". Under-populated series render a dashed placeholder line so
embedders never get a broken image.
Cases this reaches that /api/og and the raw JSON sparkline array do
not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to
posts, Notion pages. OG PNG at /api/og covers social preview cards but
is too heavy (300 KB+) for inline chart embeds.
## Dataset.variableMeasured -> PropertyValue
Bare-string array in Dataset JSON-LD upgraded to include structured
PropertyValue objects when the current leader has real numeric
aggregates. Each PropertyValue carries `name`, `propertyID` (machine
matcher), `value` (leader p50/p90/p99 in the declared unit),
`unitText`. sample_size stays a bare string — it labels an axis, not a
measurement. Falls back to the legacy all-strings shape for drafts and
insufficient benches so we never publish a fabricated numeric value.
New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts.
BenchDatasetInput.variableMeasured now accepts
Array<string | VariableMeasuredValue>. `keywords` extraction pulls the
`.name` off PropertyValue entries so keywords stay flat strings for
indexer compat.
## openapi.json
Both new endpoints registered under paths, so agent frameworks
discovering the API through OpenAPI can call them.
* sparkline: XML-escape bench title + provider name in SVG
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* bridge-monitor: Mobula EIP-712 signature-confirm flow (fixes stuck deposits) (#1160)
* bridge-monitor: implement Mobula EIP-712 signature-confirm flow (fix stuck deposits)
* mobula sig: inject EIP712Domain type definition, API omits it
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081)
* subsidy is a chain KPI, not a bench: kill standalone spec, add 3 cards to /chains/robinhood strip
* pm-api-latency: add Predexon (bench 6th provider), fresh key deployed on the 3 Railway probe regions
* usdy: 3-retry on Orca RPC to fix 40% success rate (#1168)
publicnode.com burst-limits the VPS on getAccountInfo, dropping
~60% of samples. Two retries with 500ms backoff bring hit rate to
>95% without switching endpoints. Also add commitment=confirmed
to reduce slot-miss null returns.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* bench vague 2: ship 081-085 (ws latency, oracle freshness, rpc reliability, indexer latency, block builders) (#1170)
* add bench 081 ws-head-latency: WS block-push lag harness + spec
* add bench 084 indexer-latency: HyperSync vs The Graph vs Alchemy time-to-queryable
* idx-latency: only classify error when every poll errored, else timeout
* bump bench-set cache keys for 084 (lockstep rule)
* add bench 082 oracle-freshness: staleness metrics in oracle-deviation harness (multi-chain chainlink, pyth publish_time fix, redstone), bump bench-set cache keys
* oracle-freshness: count unsupported mainnet feeds in freshness error counter
* add bench 085 evm-block-builders: ETH builder share via extraData + relay bidtraces + Arb/Base soft-conf lag, bump bench-set cache keys
* ebb: don't count historical relay payloads on first poll, retry arb calibration on zero support, reset WS backoff after stable session
* add bench 083 rpc-reliability: consensus lag, hash quorum, fixed-vector integrity in rpc-capabilities harness, bump bench-set cache keys
* bench 083: clamp_min rank_matrix_query so zero-incident providers keep cell ranks
* gate benches 081-085 behind staging until 48h clean data
* drop shipped RWA slugs from prod gate (live on main since #1162/#1167)
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hyperliquid frontend page: add ProviderLogo in the hero next to the name
* hl frontends: add slash, topdog, markets-mobile logos + registry entries; dark-mode chip for slash + markets-mobile
* idx-latency: swap Alchemy for Mobula in the cohort (paid-plan key, bench races $0 pipelines only) (#1176)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix(bench): sample-size PromQL + leader reliability floor (#1175)
* fix(bench): sample-size PromQL + leader reliability floor
Two bench-correctness fixes surfaced by an audit of the oracle and
gas surfaces we plan to cite in outreach.
Bench 025 (oracle-deviation):
- sample_size was 'count(count by (source) (ocb_oracle_price))'
which returns the count of distinct sources (3 or 4), not the
count of samples over the window. The citable API therefore
reported sampleSize=3-4 on every pair, at odds with the spec's
'polled every 30s' claim. Rewrites the query to
'sum(count_over_time(ocb_oracle_price[24h]))' across all 10
pair entries so the number reflects actual poll volume.
Leader ranking:
- citationCandidates() now excludes providers with a measured
success rate below 50 percent from the leader pool. Before this
fix, gas-estimation showed Owlracle as #1 with successRate=6.48
and a p50 gap of 0.001 gwei: technically accurate on the rare
successful poll but useless as a leader claim. Falls back to the
full live pool when every provider is under the floor so a
totally degraded bench still surfaces a best-of-bad-options
leader rather than vanishing. Freshness benches without a
success query default to 100 percent in the loader so the guard
is inert there.
Adds src/lib/citation.test.ts with 5 tests covering the Owlracle
scenario, higher-is-better ranking, and the degraded-pool
fallback.
* fix(bench): propagate reliability floor to rankings + template + hub
Follow-up on the previous commit. Review flagged that filtering the
leader without filtering the ranking arrays leaves self-contradictory
JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle
on the same document.
Exports citationCandidates and adds rankedCandidates helper that
returns the same eligible pool sorted by the bench's higher_is_better
direction. leader() and fieldValue() now share it.
Wires the shared helper into every surface that emits a rankings-like
list or a best-of claim:
- src/app/api/stat/[slug]: rankings array now sorted from
rankedCandidates so rankings[0].slug always equals leader.slug on
the same JSON.
- src/app/api/llm-context: the Rankings numbered list under each
bench matches the Headline sentence above it.
- src/app/api/mcp/[transport]: get_benchmark tool response and the
openchainbench://benchmark/{slug} resource both share the pool.
- src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}}
tokens in bench copy resolve against the same pool so a bench body
never crowns a provider its own headline excludes. Per-slug tokens
{{p50:some-slug}} still resolve against the raw live pool so unknown
tokens still fall through.
- src/lib/providers: rankProviders (drives /products hub wins count
and per-chain leadership chips) now runs on the reliable pool.
Adds a consistency test locking that rankedCandidates[0].slug always
matches leader().slug on the same bench. Total: 6 tests in
citation.test.ts, 95/95 pass across src/.
Uses (r.successRate ?? 100) for the numeric guard to future-proof
against ProviderResult shapes that skip the field. Load path still
defaults to 100 (materialize/load.ts:748) so this is inert today, but
the extra ?? removes the fragility.
/api/compare/[a]/[b] is intentionally left unfiltered: the user
picks the two providers explicitly, so the head-to-head is a
requested comparison rather than a leader claim. Applying the floor
there would drop rows the user explicitly asked to see.
* fix(hub-card): honor citation leader in the hub grid headline
Follow-up review flagged that benchmark-card.tsx re-sorted b.results
locally and picked results[0], so a bench where citation demotes the
raw-p50 winner (e.g. Owlracle on gas-estimation) would show the
demoted provider on the hub grid while the bench page names the
correct leader. Two documents contradicting each other in one click.
Adds leaderSlug: string | null to BenchmarkCardData and populates it
at the projection boundary via leader(b), which already applies the
reliability + insufficient-sample filter. The card picks the leader
by matching that slug in the projected results, and falls back to the
raw best when the projection returned no leader (draft, insufficient,
or all providers filtered out) so a card still renders a headline
value in every state.
Kept the local sort so the fallback path and any downstream chip
using sorted[0].name keeps working.
Test suite unchanged: 95/95 pass across src/. No test needed since
this is a projection-boundary wire-up.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix(registry): remove duplicate topdog entry (#1177)
Commit 349b786 (hl frontends: add slash, topdog, markets-mobile logos +
registry entries) added a 'topdog' entry at line 313 without noticing a
pre-existing 'topdog' entry at line 1061, breaking staging build with a
duplicate object literal type error.
Keeps the newer entry (Telegram bot at t.me/topdog_trade_bot, HL
frontend context) since it was the one intentionally added for the
Hyperliquid frontends leaderboard chip work. Drops the older Discord
signal bot entry (topdog.gg): the bench spec at
benchmarks/hyperliquid-frontends.yml has slug: topdog with name TopDog
and matches the newer, HL-native product.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* vague2 QA: unit sec for true-seconds benches (fixes 0.0s heros), soften 080 findings, drop dead 085 builder rows, 083 expected_n 40 (#1178)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster (#1179)
Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).
Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
number, returns parentHash as the observed hash. Same latency
measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
polkadotStaleBlockGap = 40. Consensus observation is skipped
because chain_getHeader returns parentHash (N-1), not the current
block hash (deriving current hash needs Blake2 over the SCALE-
encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
Polkadot state accesses via state_getStorage keyed by Blake2 storage
keys, no chain-agnostic depth analog for eth_getBalance-by-depth.
Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
Substrate keyless)
Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.
Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
rpc-capabilities benchmark, chain cluster page and hub card
render it identically.
- Methodology documents the Substrate specifics: probe method
choice, staleness scaling, why no archive-depth or hash-quorum
columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
/ {{p50:slug}} template surface, consistent with other -rpc
benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
Foundation grantee status so the leaderboard reads correctly
for someone who knows the Polkadot infra landscape.
Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
citation reliability floor and consistency tests added in #1175.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* product pages: 9 block-builder registry entries + verified logos, oracle logos (pyth/redstone) (#1181)
* products: registry entries + logos for bench 085 block builders
* oracle logos: redstone official logo + pyth/redstone manifest + registry entries
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* 085: add bobthebuilder provider row (real share, was metrics-only) (#1182)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184)
* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster
Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).
Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
number, returns parentHash as the observed hash. Same latency
measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
polkadotStaleBlockGap = 40. Consensus observation is skipped
because chain_getHeader returns parentHash (N-1), not the current
block hash (deriving current hash needs Blake2 over the SCALE-
encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
Polkadot state accesses via state_getStorage keyed by Blake2 storage
keys, no chain-agnostic depth analog for eth_getBalance-by-depth.
Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
Substrate keyless)
Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.
Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
rpc-capabilities benchmark, chain cluster page and hub card
render it identically.
- Methodology documents the Substrate specifics: probe method
choice, staleness scaling, why no archive-depth or hash-quorum
columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
/ {{p50:slug}} template surface, consistent with other -rpc
benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
Foundation grantee status so the leaderboard reads correctly
for someone who knows the Polkadot infra landscape.
Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
citation reliability floor and consistency tests added in #1175.
* polkadot page + parity/onfinality/dwellir products
Wires the /chains/polkadot hub page, provider profiles, and logo
assets that surface once bench 086 polkadot-rpc starts feeding Prom.
- chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT).
Auto-populates /chains/polkadot once benches referencing chain="polkadot"
land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables
when the chain-kpis harness starts sourcing Polkadot alongside the
other L1s.
- provider-registry.ts: three new entries for the Polkadot cohort.
* parity: brand entry for Parity Technologies (Polkadot / Substrate
implementation shop, operator of rpc.polkadot.io).
* polkadot-official: endpoint-level entry describing the reference
rpc.polkadot.io keyless endpoint; providers.ts aliases the slug
to parity so the /products page collapses under the operator's
brand.
* dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless
Substrate infra) — excluded from the 086 launch cohort due to a
503 during the audit sweep but kept in the registry for the
revisit and for other places their brand might appear.
- providers.ts: add polkadot-official → parity alias and canonical
brand casings for polkadot / parity / onfinality / dwellir so the
/products hub, badges, and OG images render the correct name
without the title-case fallback butchering them.
- logo-manifest.ts: polkadot.png (chain hero), parity.png (product page
logo), plus polkadot-official → parity slug alias so the endpoint
chip on the bench page picks up Parity's mark automatically.
95/95 pass on bun test src/. Local build clean.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* store: last-known-good snapshot fallback (no more awaiting after first publish) (#1188)
publishSnapshot writes an lkg copy without TTL on every success;
readMaterialized falls back to it when the pointer/blob pair misses
(worker outage past blob TTL, DEL race, transient). dataAsOf keeps the
freshness badge honest about age. LKG write is best-effort and never
fails the publish.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* chain-kpis: add polkadot to registry (DOT native, stables via Asset Hub) (#1191)
Ships live KPIs on /chains/polkadot. DOT price and mcap flow through
Mobula. Stables card renders via DefiLlama Asset Hub aggregate.
TVL and DEX gauges stay unpublished (relay chain has zero DeFi:
parachain TVL lives under Acala, Moonbeam, Hydration).
Also guards defillama TVL publish to skip zero values so chains that
DefiLlama tracks by name but with an all zero series (Polkadot today)
hide the TVL card instead of rendering $0.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* live-feed status: Feed down pill + banner on aggregator-head-lag (#1193)
Short-window changes() probe per provider surfaces silent live feeds
that the 24h quantile aggregate hides (Codex WS blackout 08:23-09:04
UTC today moved the p50 by nothing but the whole feed was mute for
40min). Bench-level probe_ok gate suppresses badges when our harness
side is broken so a Railway crash cannot fake-flag every provider.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* series gaps: null the chart line when a feed goes silent (unless changes==0) (#1195)
Companion to feed-down banner: makes the 40min Codex blackout from
this morning (08:23-09:04 UTC) visible on the 24h chart instead of a
flat line covering the outage. Per-series match preserves the
aggregate line as long as one chain still receives events.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* add bench 087 ws-head-latency-base + 088 ws-head-latency-solana (#1196)
Per-chain siblings of bench 081 for the WebSocket head-lag cluster.
Same harness (harnesses/ws-head-latency), same race scoring, one
spec per chain to avoid mixing block cadences (Ethereum 12s vs Base
2s vs Solana 400ms) in a single leaderboard.
Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does
not expose a keyless Base WebSocket path.
Solana cohort: PublicNode + Solana Foundation. slotSubscribe
replaces newHeads on the harness side; metric names are shared so
the queries are structurally identical to the EVM specs.
Data already flows via the Paris VPS deployment (chain label carries
base and solana). No harness change needed for this ship. Multi-region
is a follow-up on the parent bench.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: multi-region (harness region label + spec region tabs) (#1198)
* ws-head-latency: region label via ConstLabels + Railway config
Adds a static region ConstLabel to every ws-head-latency metric,
bound at process startup from OCB_REGION (preferred) or
RAILWAY_REPLICA_REGION (Railway auto-set), with eu-west fallback
matching the historical single-vantage Paris VPS deployment.
No observation site needs to change: the label is baked into every
counter, gauge and histogram at registration, so all existing
WithLabelValues(provider, chain) calls stay identical.
Adds railway.toml so Railway can build the harness from Dockerfile
with a /health check, matching the rpc-capabilities service layout.
Follow-up: create the Railway ws-head-latency service with 3
replicas (us-east, eu-west, sgp) and OCB_REGION set on each, then
decommission or leave the Paris VPS container as a 4th vantage.
* ws-head-latency specs: add region dimension tabs (081 + 087 + 088)
Adds `dimensions.region` block + `rank_matrix_query` on provider,
region + per-provider `regions:` sub-arrays with region-scoped
queries on the 3 ws-head-latency specs, following the polkadot-rpc
pattern.
Depends on the harness region label change (previous commit): every
metric now carries a region ConstLabel so the region-scoped
histogram queries return values as soon as multi-region replicas
publish. Until Railway 3-replica deploy lands, only region=eu-west
has data and the other tabs render empty; the All regions tab keeps
working because the aggregate query is unscoped by region.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: align region env var with rpc-capabilities (REGION not OCB_REGION) (#1200) (#1201)
Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* chart: red band overlay for null-bucket downtime windows (#1203)
Companion to #1195 (series null gaps). The gaps themselves render as
line breaks but a break can read as 'the chart missed a sample'. A
soft red column behind the break spans the exact outage window and
makes the reader understand it was a real feed-side silence. Per
line + per contiguous gap range, extended by one step so 1-bucket
outages stay visible instead of collapsing to a zero-width sliver.
Hidden for excluded (legend-toggled) providers.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* rename ws-head-latency -> ws-head-latency-ethereum for sibling slug parity (#1204)
081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve).
Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* network-coverage: drop coinstats (free tier credits exhausted) (#1206)
CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned.
Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API).
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* Revert "network-coverage: drop coinstats (free tier credits exhausted) (#1206)" (#1208)
This reverts commit d295f7168da981078164119e508ca9546ed69fa2.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime bands: provider-colored tint + name/silent label + edge markers (#1210)
Feedback on #1203: the neutral red band read as 'something happened
here' without telling the reader WHO. Tint each band in the
provider's own color, add dashed edges at the outage boundaries, and
label 'PROVIDER · SILENT' above the band when it fits (with a caret
that survives on narrow single-bucket bands). Works uniformly for
Mobula/Codex/Gecko or any future provider with a live_activity
query.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime bands: pill labels + vertical stacking on overlap (#1211)
Screenshot review: the small text-only label above the band was still
too subtle to read as 'this provider is down', and 3 simultaneous
provider outages produced overlapping unreadable labels stacked on
the same y. Replace with high-contrast rounded pills (white text on
provider color) that stack vertically when their X-ranges overlap.
Renders as '● NAME DOWN' in bold small caps so a reader gets the
event and the provider identity at a glance.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tsp-arb-latency: lower trigger 50 -> 25 bps + credit sub-poll closures (#1212)
Bench 080 published only 7 samples across 3 of 11 assets in 24h at
50 bps trigger. Two contributing causes fixed together:
1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on
Nasdaq (earnings, macro prints), not a steady-state event. 25 bps
is above normal intra-minute noise on liquid stocks and captures
real arb-worthy moves without collapsing the semantic.
2) Sub-poll convergence credited. When the reference moves past the
trigger AND the pool is already within band on the same tick, the
arb closed inside one poll interval. Previously dropped silently;
now observed at pollInterval (60s) so the fast-arb tail on liquid
pools stops disappearing from the histogram.
Convergence threshold stays at 20 bps: the "how close is the pool to
fair" question is unchanged, only trigger sensitivity moves. Spec
text (subtitle, methodology, FAQ) updated to 25 bps throughout.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime pill: drop the leading dot indicator, center the label (#1213)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime pill: 'DATA MISSING' wording + skip leading gaps (#1215)
Two fixes based on 30D view review:
- Leading gaps (Prom retention shorter than the visible window,
harness started mid-range, provider added recently) rendered as
a chart-wide band claiming every provider was down for the
missing period. Skip runs that started before the first observed
sample.
- Rename 'DOWN' to 'DATA MISSING'. A silent series can be the
provider going down, our harness losing its WebSocket, or a Prom
scrape failure — the pill stays neutral instead of blaming the
provider.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* drop bench 084 indexer-latency (harness pruned, spec deleted) (#1218)
HyperSync and The Graph need paid keys for the sustained cadence the
bench expects; Mobula alone left the leaderboard single-provider and
the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to
drop the whole cluster than to buy 2 SaaS subscriptions to prop up
a bench nobody has cited yet.
Removed:
* benchmarks/indexer-latency.yml
* harnesses/indexer-latency/ (whole directory)
Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns
Gone rather than 404. Cache keys bumped so cached v30/v33 entries
that still enumerate the slug age out on next revalidate.
VPS follow-up (separate ops): stop ocb-indexer-latency container,
remove from docker-compose.yml + prometheus.yml scrape config.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* video/render: strict Zod body schema before upstream forward (#1216)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* mcp: allowlist perp_funding_*, perp_venue_*, perp_execution_* prefixes (#1220)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: drop 'unless changes==0' from series queries (#1221)
Bug: the guard was designed for modern 15s-cadence data, where
changes([15m]) works cleanly. On backfilled historical data compacted
to ~1 sample/hour, changes([15m]) returns 0 even though the gauge is
healthy — the 30D view lost the first 8 days of data to false-drops
(all three providers marked DATA MISSING for a whole week).
Real-time silence is still surfaced by the live_activity scalar
(pill + banner + downtime band), which runs against fresh scrape
cadence and stays accurate. The chart series now shows every point
Prom has, flat during old outages instead of gapped.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* seo: 200 placeholders + sitemap threshold buffer (#1222)
- badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data
- hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder
- compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate
Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: restore outage detection with backfill-safe guard (#1224)
Follow-up to #1221. Dropping the guard entirely restored the 30D
range but silently hid today's Codex morning blackout on the 24H
view (frozen gauge value = flat line, indistinguishable from a
healthy quiet period).
Bring the guard back, gated on count_over_time > 5 samples in the
window. Modern data at 15s cadence carries ~60 samples per 15 min
so the gate fires the moment a WS goes silent. Backfilled data
compacted to ~1 sample/hour has count <= 2 in a 15m window so the
gate stays open and every point Prom kept is served — no more fake
wall-to-wall gap at the start of the 30D range.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: sub-ms histogram floor + keyed slots for base/solana (#1225)
Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a
[0,5ms] first bucket interpolates every winner's p50 to ~2.5ms
(chain-independent). Replace with a 0.5ms floor climbing to 10s so
sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s).
Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode)
and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can
extend beyond keyless once contributor keys are wired. Keyless tier
is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC,
BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack-
public — none accepted a keyless WSS handshake as of 2026-07).
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ship: ungate bench 085 evm-block-builders on prod (#1227)
Titan leads with sample=6973 in 24h, data healthy since the harness
ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod
next revalidate. Cache keys bumped to bench-unfiltered-v32 +
all-benchmarks-v35 so cached entries do not keep dropping the slug
via the old prod gate.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* loader: propagate 24h series null-runs to 7d/30d (#1229)
Fine-grained outages (24h step = 20 min buckets) rarely align with
coarser evaluation timestamps (7d step = 2h, 30d step = 12h) so the
Prom query returns a healthy average and the reader sees the pill on
24H but nothing on 7D/30D. Post-process: for every contiguous null
run detected on s24, translate the time-ago range to bucket indices
on s7 and s30 and null every coarse bucket the run straddles.
Contiguity preserved so a single short outage doesn't split into two
adjacent pills after propagation. Same treatment applied to per-region
series.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* propagateNullsToCoarser: use absolute time-ago instead of window fraction (#1230)
Bug: the previous math scaled by the fine window fraction, which
projected a 7h-ago outage down to 40h-ago on the 7d grid (the pill
landed at -1.7d instead of -6h). Both grids share 'now' as their
right anchor and only the step sizes differ — take the fine idx's
absolute time-ago and divide by the coarse step to find the
covering bucket.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL AMZN MSFT SPY) (#1231)
Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* perp-fees harness: fix Paradex depth-skip, gains SOL slot, dYdX walk order, GMX Subsquid error surfacing (#1233)
- Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values.
- gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL.
- dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers.
- gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path.
Verified: go build + go vet clean.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime bands: opt-in via live_activity, skip on other benches (#1235)
Screenshot review: rpc-reliability rendered a 'LAVANODIES DATA MISSING'
pill even though its spec never declared live_activity — natural
nulls in its series24h (sparse scrapes, backfill edges) triggered
the DowntimeBands render. Gate the render on
benchmark.results.some(r => r.liveStatus != null), which is only set
when the spec declared live_activity. Same gate on
propagateNullsToCoarser so other benches' 7d/30d series stay
untouched.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* batch 2: MEV chips + rpc copy + Aster/edgeX + Cardano/Sui/Cosmos/Avax (#1236)
* site: provider_notes chip on ranking rows + rpc-capabilities copy fixes
- validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat
- New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render)
- Chip renders only when the note is set, no visual change to any other bench
- rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ
- rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST)
* perp-fees harness: add Aster + edgeX venues
- Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API)
- edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0)
- Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers
- Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume)
* validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche
Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains.
- Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io.
- Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs.
- Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission).
- Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100).
Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* rpc-capabilities: add 4 keyless providers (ThirdWeb Blast Gateway.fm bloXroute) (#1238)
Broadens the keyless cohort for benches 010 (rpc-capabilities), 083
(rpc-reliability) and the per-chain RPC benches. All 4 providers
verified live returning valid eth_getBlockByNumber on their
respective chains before inclusion.
New (provider, chain) cells:
ThirdWeb 8 chains (ETH POL ARB OP BASE BSC AVAX LINEA)
Blast API 4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier
bloXroute 3 chains (ETH BASE BSC)
Gateway.fm 1 chain (ETH)
Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix
groups by provider+chain) and the per-chain leaderboards after the
Railway rebuild picks up main.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: add Onfinality Ethereum to keyless cohort (#1241)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* drop oracle-freshness (082) spec + sync REMOVED with main state (#1245)
Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal.
Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader).
Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state.
Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* SEO/E-E-A-T pack: /team page, Person schema, 23 positioning, 8 expert_take (#1249)
Adds a broad SEO and GEO upgrade landing three surfaces at once:
- /team page with Florent Tapponnier as Maintainer, sameAs GitHub +
LinkedIn, Person JSON-LD wired into Organization.founder and into
every bench StatisticalReport.contributor + TechArticle.author +
reviewedBy so YMYL crypto queries see a named human accountable.
- 23 positioning fields on alternatives specs (200 words each,
factual, sourced from official docs, fact-checked via WebFetch).
Cuts the shared-template surface from ~65 percent to ~30 percent
across pages that reference the same benchmark.
- 8 expert_take fields on answers specs (150-200 words each,
structural commentary that stays valid regardless of who currently
leads the leaderboard) with QAPage schema so the acceptedAnswer
(Org, auto) and suggestedAnswer (Person, expert_take) both reach
Google + AI Overviews.
- Byline on answers pages when expert_take is present, linking to
/team, hidden when dataPending so editorial context never renders
next to an empty leaderboard.
- noindex on awaiting benches so Prom outages do not surface thin
placeholder pages to crawlers.
- Optional Zod fields positioning + expert_take + expert_take_reviewed
with noAiDashes refine so future drafts stay in policy.
- Sitemap prebuild script emits real git-commit timestamps for the
8 editorial pages so lastmod stops resetting to build time on every
unrelated Vercel deploy.
- scripts/editorial-check.sh lints the two new fields for em/en
dashes, marketing adjectives, pronouns, year references.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* alternatives: render positioning + clean paragraph line-wrap (#1250)
Two fixes on top of #1249:
1. Add a Positioning block on /alternatives/[slug]/page.tsx that
renders the newly-added field. Phase 1 added the Zod schema
field but never wired the UI, so the content sat in YAMLs
invisible on staging.
2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
single line per paragraph. Previous version hard-wrapped at
180 chars, which YAML literal (|) preserves as newlines,
splitting words mid-token in the rendered HTML.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hotfix: strip glued expert_take_reviewed from status line (#1251)
* alternatives: render positioning + clean paragraph line-wrap
Two fixes on top of #1249:
1. Add a Positioning block on /alternatives/[slug]/page.tsx that
renders the newly-added field. Phase 1 added the Zod schema
field but never wired the UI, so the content sat in YAMLs
invisible on staging.
2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
single line per paragraph. Previous version hard-wrapped at
180 chars, which YAML literal (|) preserves as newlines,
splitting words mid-token in the rendered HTML.
* fix: strip glued expert_take_reviewed from status line
The previous rewrite regex dropped the newline before existing
expert_take_reviewed values without adding a proper break, so
'status: live' and 'expert_take_reviewed: ...' ended up on the
same line, failing YAML parse and killing the build export step.
Also collapsed duplicate expert_take_reviewed lines that landed
during the double-run of the rewrite script.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hotfix: strip glued status line in 8 answer YAMLs (#1252)
Fix that landed in a prior branch was lost during a squash-merge
sequence. Reapplying directly off dev to guarantee it lands.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* answers: drop visible byline on expert_take blocks (#1253)
The visible "By Florent Tapponnier, reviewed <date>" byline
implied authorship of every expert_take paragraph and dated the
review with a fabricated timestamp, both editorially fragile.
Keep the E-E-A-T signal at the schema layer only: Person JSON-LD
on Organization.founder, StatisticalReport.contributor, TechArticle
author + reviewedBy, and QAPage suggestedAnswer.author. Search
engines see the human attribution where it counts; readers see the
editorial commentary without a personal claim on every crypto
opinion the site publishes. Removes the unused Byline component too.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* drop bench 080 tokenized-stock-arb-latency (Rob Chain pools too illiquid) (#1254)
5 of 11 tickers had frozen pool prices (no swap activity). Remaining 6
had p99 pinned at the 32min histogram cap because arbs rarely close
within the settle window. Robinhood Chain launched Mar 2026, tokenized
equity market still too young for a meaningful arb-latency bench.
Removed:
* benchmarks/tokenized-stock-arb-latency.yml
* harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
* arb_tracker calls + closeStaleOnStateChange in main.go
Kept: bench 077 tokenized-stock-peg + 079 weekend-drift (static peg
measurements) stay live on prod as the honest signal for this cluster.
Slug in REMOVED_BENCH_SLUGS for 410 Gone. Cache keys bumped.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* jsonld: add 'name' to TechArticle nodes for Google Rich Results validation (#1257)
GSC flagged 'Missing field name' on aggregator-head-lag JSON-LD (and by extension every bench slug page + every per-chain page + the methodology hub — all TechArticle nodes shipped only 'headline'). Schema.org Article inherits 'name' from CreativeWork; Google's Rich Results validator treats it as required even when 'headline' is present. Add 'name' alongside 'headline' with the same value on all three affected builders.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-card: propagate region/kind/venue filters + compose dynamic title (#1260)
Feedback from Maxime: exported images do not adapt to the active
filters on the bench page. A share-card exported from
/benchmarks/rpc-capabilities?chain=ethereum®ion=sgp used to render
just 'Fastest free public RPC ...' with cross-chain data; now the
loader picks up the region filter and the title reads 'Fastest free
public RPC ... Ethereum · Singapore' with the correctly scoped data.
Change:
* share-card route reads region, kind, venue URL params (symmetric
with the existing chain param). Each maps to a spec dimension and
forwards to the materialize loader so the data is filtered too,
not just the title.
* A composed contextParts string is built from the resolved dimension
labels and appended to the aggregate title. Empty when no filter
is active, no trailing separator on the default view.
* The composed title is overlaid onto the benchmark passed to each
render (spread with {...b, title: displayTitle}) so all 5 templates
(ranking, leaderboard, snapshot, headline, compare) pick it up
without any render-signature changes.
* share-section + share-section-modal read live URL params for
region/kind/venue in addition to chain when composing the card URL,
so a modal opened from a scoped bench page passes the same scope
to the exported PNG.
Follow-up (not in this PR): view / range / topN selectors, per-bench
UI in the modal to expose the dimension pickers explicitly. This PR
is the plumbing; the modal UX for exposing new pickers is next.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)
This reverts commit b0ce3ef564fe4ca2aca905245c0d4dd1c6373054.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* Revert "Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)" (#1263)
This reverts commit d7f0fcbbbb6ad0db967c285fd23f99ca76b066f5.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: peer-activity clause (on/chain,region) fixes pool-silent false positives (#1265)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-card: filter negative p50 in sortByP50 (fixes HTTP 500 on RPC benches) (#1266)
Commit c03c599 relaxed the sortByP50 filter from `p50 > 0` to `p50 !== 0` to support the negative-p50 'warming up' sentinel introduced for funding benches on the /products page. The relaxation leaked into the share-card render path, where negatives crash Satori: negative p50 values fed to Math.max produce a negative maxP50, which inverts every bar height ratio and blows up the JSX with an opaque 'Spread syntax' error mid-render.
Symptoms observed: /benchmarks/{rpc-capabilities,polkadot-rpc,ws-head-latency-ethereum,evm-block-builders}/share-card?template=ranking all returning HTTP 500. Working benches (network-coverage, perp-fees, oracle-deviation) had no negative sentinel values in their data.
Fix restores `p50 > 0` in the share-card filter. The negative sentinel handling stays on the /products page where it was designed to render (as 'warming up'). Share-card treats negatives as noise and drops them, same as zeros.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-modal: explicit dimension pickers (chain / region / kind / venue) (#1267)
Feedback from Maxime: user needs to be able to pick chain/region/kind/venue from within the share modal, not just inherit whatever the URL had when opening. Also the composed card title must reflect the pick in real-time.
Adds a DimensionRow chip component rendered above the template tabs. One row per dimension the bench declares (chainDims / regionDims / kindDims / venueDims). Each row: 'Label' + 'All' chip + one chip per option. Clicking a chip toggles state; clicking the active pick clears back to All.
State is seeded from the live URL on mount so the modal opened from a scoped bench page starts on the same scope (unchanged behaviour). User can override in-modal by clicking a different chip; state updates re-render the card preview with the new filter forwarded to the loader.
The cardSrc URL builder now reads from picker state instead of the live URL - previous implementation re-read the URL every render which meant the modal's own state changes couldn't propagate. Same URL param names on the share-card route: no server-side change needed.
Bench without dimensions: block hidden entirely, modal looks unchanged.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: ws_connected guard filters out harness-reconnect false positives (#1269)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hotfix(share-card): revert region/kind/venue passthrough + title overlay (#1270)
The region/kind/venue param plumbing (added via #1260 → reverted via
#1261 → re-added via #1263) and the display-title overlay via
{...b, title: displayTitle} still cause HTTP 500 on rpc-capabilities
share-card and other benches with dimensions. My sortByP50 fix
(#1266) turned out not to be the actual root cause; the runtime error
persists after that fix + the modal picker PR (#1267).
Reverts the route.tsx changes only:
- Restore original getBenchmark({chain}) call (no region/kind/venue
forwarding)
- Restore original switch that passes plain benchmark/filteredSafe
to renders (no {...b, title: displayTitle} spread)
- chainLabel still passed through (unchanged from pre-Maxime-feedback)
Keeps the modal DimensionRow pickers (#1267) intact for a future
retry once the true root cause is found. They currently look for
matching URL params but the server-side won't act on them until the
route.tsx re-adds forwarding safely.
Bug still open on rpc-capabilities/polkadot-rpc/ws-head-latency-*/
evm-block-builders share-cards, but now user-visible functionality
(chain filter, existing templates on non-broken benches) is fully
restored to pre-Maxime-feedback behaviour.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: strip ws_connected clause from geckoterminal (REST-only, no ws metric) (#1272)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-card: fix HTTP 500 on RPC benches (Satori AVIF/WebP + CSS var crashes) (#1273)
Two independent bugs causing pre-existing HTTP 500 on rpc-capabilities,
polkadot-rpc, ws-head-latency-*, evm-block-builders share-card
endpoints. Reproduced locally with bun dev + shimmed try/catch to
unmask the mid-stream Satori errors.
Bug 1: Satori (the JSX-to-SVG renderer next/og uses) only decodes
PNG / JPEG / SVG. Feed it AVIF or WebP bytes and it throws
'TypeError: u2 is not iterable' mid-render, surfacing as opaque
HTTP 500. publicnode.avif, drpc.webp, lava.webp all trigger this on
the RPC benches. Fix: skip AVIF/WebP extensions in
getProviderLogoDataUrl, fall back to the initials chip.
Bug 2: chipBackground / chipTextColor from src/lib/brand.ts return
CSS var references (e.g. 'var(--color-ink-soft)') when the provider
has no registered brand color. Satori evaluates var(...) to the
CSS-wide 'initial' keyword and rejects 'background: initial',
crashing the whole PNG. evm-block-builders is a full-cohort case
(Titan, BuilderNet, Quasar, Eureka, Builder+, Vanilla — none have
brand colors or logos). Fix: resolve var(...) fallbacks to the
concrete INK_SOF…
Flotapponnier
added a commit
that referenced
this pull request
Jul 25, 2026
* solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries
* nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* nft harness: delist rarible, free tier is 100 requests per month (#1137)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees
* bridge-monitor: on-chain balance fallback + degraded flag
* bridge-monitor: self-healing metrics + slack notifiers
* bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge
* bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight
* bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer
* bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default
* bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset
* bridge-monitor: keyless dry-run support for reaper + RunDryRun
* bridge-monitor: comment style cleanup in pre-flight docs
* bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive
DailySpentUSD only ever incremented and was zeroed by every restart, so a
crash-loop minted a fresh budget each time. The tracker resets exactly at
UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default
./spend-state.json, not /tmp) and is mutex-guarded. Also declares the
package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD
(default 0.50) helper for gas bled by failed broadcasts.
* bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock
Fund-loss fixes from hostile review:
- a corrective transfer whose deposit broadcast but whose bridge status
never resolved is now TERMINAL for the scheduler slot (funds are likely
still in flight, a retry double-sends); only pre-broadcast failures may
consume another attempt. executeOnBridge keeps TxHash on error paths so
callers can tell the two apart.
- ONE corrective-transfer budget (max 2) per scheduler slot, shared across
all downgrade-ladder rungs (was per rung: up to 6 transfers per slot).
- package-level execution mutex: scheduler slots own the wallets end to
end, the reaper TryLocks and skips its tick instead of queueing.
- broadcasts that failed or timed out after getting a TxHash book a flat
FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding
unaccounted gas; all spend goes through the persisted UTC-day tracker.
- gas top-up now honors MaxDailySpendUSD before broadcasting.
* bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode
The reaper (and any other consumer) used to accept simulated balances even
in production with broadcast-capable keys, so phantom numbers could
green-light real transfers. SimulateBalances now takes the execution mode
and returns nil outside dry-run; startup logs a warning when the flag is
set in a broadcast-capable mode.
* tx executor: preserve hash on ambiguous send errors, split dry-run spend state
* executor: add missing chain label, first real single-test panicked post-fill (#1141)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated)
* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tsp: parse the flat spark shape (close/timestamp arrays), log empty parses
* tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes
* rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages
* rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family
* rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions
* registry: googl links to google.com, abc.xyz 403s aggressively
* cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves
* fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152)
* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident)
* bridge-monitor: sub-50ms buckets + nanosecond precision on Observe
Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000]
to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents
on solver-cached corridors (HyperCore in particular) returns via a
1Click coordinator fast path in <50ms, and the previous scheme lumped
every one of those observations into (0, 50] so histogram_quantile
linearly interpolated to ~25ms regardless of true value. The p50 shown
on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a
bucket floor, not a measurement. Sub-50 buckets let the real bimodality
surface.
Also swap Observe(float64(quoteLatency.Milliseconds())) for
Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6
per-bridge observers (across, debridge, lifi, mobula, nearintents,
relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0,
same bug class as the RPC harness fix in PR #1128. Not a hot path here
(network RTT dominates), but keeps precision consistent with the new
low buckets and cheap to fix while we are already touching each file.
Bench YAML methodology + FAQ updated to reflect the new bucket set.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154)
* fix: 3 machine-readable surface bugs surfaced by GEO audit
1. /api/citable/{date} unit scaling.
Snapshot route emitted `value: 627` for `aggregator-head-lag` while the
live /api/citable emitted `value: 0.63` on the same measurement. The
live route already wraps with valueInDeclaredUnit(raw, b.unit) but the
snapshot route was left with the raw internal-ms value. Wrap the same
way; sub-1 s benches now render in the declared `s` unit on both routes.
2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=.
getBenchmark accepted a filters object but the route never parsed the
URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the
cross-chain aggregate. Parse the four dimension params, pass through
to the loader; unknown values fall back to unfiltered inside the
loader. Also widen getBenchmark options type to include kind and
venue — the underlying loadBenchmark already supports both.
3. Draft benches leaked a fake freshness signal.
Draft placeholder set lastRunAt to new Date for type safety
(Benchmark.lastRunAt is non-null), and both citable routes passed it
straight through as asOf. LLM crawlers treating asOf as ground truth
would think a draft bench was measured every minute. Null asOf in the
JSON when status is draft; the internal type stays intact.
* fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified
The GEO audit review found the initial fix in commit f6f84b8 only covered
the two /api/citable JSON surfaces, but the same "draft benches spoof
freshness" class of bug lives on three more machine-readable channels
that LLM crawlers also key on:
- /api/stat/{slug} asOf field.
- MCP tool responses (list_benchmarks, get_benchmark, resource template,
and the plain-text "Last sample" line).
- JSON-LD dateModified on the bench page, per-chain sub-page, answer
page and alternatives page — Google, Bing, Perplexity all consume
dateModified as a freshness ranking signal.
Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every
call site. Draft benches now uniformly omit or null the freshness
timestamp; live benches unchanged. On the visible /answers page,
the "Data as of..." line and its <time dateTime> anchor also hide for
drafts rather than displaying the wall-clock placeholder.
Follow-up to PR #1154 (commit f6f84b8) covering the surfaces the review
agent flagged as still spoofing.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* feat: /api/compare/{a}/{b} + openapi.json coverage (Wave 1 P0/P1) (#1156)
* feat: /api/compare/{a}/{b} + openapi.json coverage
Two GEO surfaces flagged by the audit as high-value gaps.
## /api/compare/{a}/{b}
Head-to-head comparison as a single citable payload. LLMs asked "Mobula
vs Codex head lag" would otherwise need two /api/stat calls plus their
own delta reasoning; this endpoint returns one flat structure with per
benchmark p50 for each side, the winner on the metric's
higher-is-better convention, and the absolute delta.
The intersection logic mirrors src/lib/related-providers.ts and the
HTML /compare/{slug} page: shared benchmark = both providers appear in
it. Intentionally returns flat leader-p50 only (no chain/region
breakdowns, no cache-heavy fan out) so the agent tool call stays fast.
## openapi.json coverage
Added /api/citable/{date} (was missing despite being a real API
endpoint) and /api/compare/{a}/{b} to the OpenAPI 3.1 spec. Also
documented the dimension query params (chain/region/kind/venue) on
/api/stat/{slug} that shipped in the previous PR but were undocumented.
Added Compare schema; extended Stat schema with `filters` field.
/llms.txt and /llms-full.txt stay outside the OpenAPI surface — they
are text files not RPC endpoints, and OpenAPI consumers do not handle
plain-text specs well anyway.
## citableAsOf helper widened
Type signature relaxed from full Benchmark to
`Pick<Benchmark, "status" | "lastRunAt">` so the slim
ProviderAppearance.benchmark used by the new compare route can pass
through without a downcast. No behavior change on existing callers.
## Notes on items NOT shipped in this batch
- Cache-Control on /llms.txt / /api/citable / /api/llm-context: the
audit report showed `cache-control: public` and inferred no CDN
caching, but the source already sets
`public, s-maxage=300, stale-while-revalidate=900`. Vercel strips the
`s-maxage` directive from the client-visible header while honoring it
at the edge; `x-vercel-cache: HIT` with `age: 256` confirms the CDN
cache is active. Not a real bug.
- .bib / .ris file MIME endpoints: string versions already ship inside
the /api/stat JSON via cite.bibtex / cite.apa. Marginal upside for
Zotero-style one-click imports; downgraded to P2.
- /api/stat/{slug}/sparkline.svg: sparkline data already ships as a
float array in /api/stat and the OG image at /api/og/{slug} covers
the "embed a chart in an answer" case. P2.
- JSON-LD `Dataset.variableMeasured.value`: current schema emits bare
strings; upgrading to PropertyValue objects would duplicate the
measured value already carried by the StatisticalReport node's
Observation.measuredValue. P2.
* openapi: add totalWins to Compare schema
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* feat: Wave 1 P2 batch — cite MIME, sparkline SVG, Dataset PropertyValue (#1158)
* feat: Wave 1 P2 batch — .bib/.ris/apa MIME, sparkline SVG, Dataset PropertyValue
Three GEO surface additions the audit downgraded to P2. Each one is
duplicative with an existing channel (cite.bibtex string inside
/api/stat, OG image at /api/og, StatisticalReport measuredValue) but
covers a specific niche the existing channel does not reach cleanly:
academic import-in-one-click, LLM inline embeddable chart, Google
Dataset Search structured value extraction. Ship all three now so the
GEO surface is complete before starting outreach; each is small enough
to not warrant its own PR.
## /api/cite/{slug}/{format}
Per-bench citation with the correct MIME type. Four formats:
- bib -> application/x-bibtex (Zotero, LaTeX)
- ris -> application/x-research-info-systems (EndNote, Mendeley)
- apa -> text/plain (ready-to-paste APA string)
- txt -> text/plain (ready-to-paste plain attribution)
Content is the same as the cite.* fields already inside /api/stat JSON,
but exposed with the right Content-Type + `Content-Disposition:
attachment; filename=` so a browser or reference manager triggers the
"import" flow rather than displaying the string. Added RIS to CiteBundle
in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2
/ ER fields, CRLF line endings per RFC convention).
## /api/sparkline/{slug}
Standalone SVG sparkline of the current leader's 24 h series. Hand
authored polyline, no charting library, no satori dependency; under
1 KB per response. Query params: `w` (40..1200, default 240), `h`
(20..400, default 60), `theme=light|dark`. Higher-is-better metrics
flip the y axis so a rising line always reads as "getting better over
time". Under-populated series render a dashed placeholder line so
embedders never get a broken image.
Cases this reaches that /api/og and the raw JSON sparkline array do
not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to
posts, Notion pages. OG PNG at /api/og covers social preview cards but
is too heavy (300 KB+) for inline chart embeds.
## Dataset.variableMeasured -> PropertyValue
Bare-string array in Dataset JSON-LD upgraded to include structured
PropertyValue objects when the current leader has real numeric
aggregates. Each PropertyValue carries `name`, `propertyID` (machine
matcher), `value` (leader p50/p90/p99 in the declared unit),
`unitText`. sample_size stays a bare string — it labels an axis, not a
measurement. Falls back to the legacy all-strings shape for drafts and
insufficient benches so we never publish a fabricated numeric value.
New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts.
BenchDatasetInput.variableMeasured now accepts
Array<string | VariableMeasuredValue>. `keywords` extraction pulls the
`.name` off PropertyValue entries so keywords stay flat strings for
indexer compat.
## openapi.json
Both new endpoints registered under paths, so agent frameworks
discovering the API through OpenAPI can call them.
* sparkline: XML-escape bench title + provider name in SVG
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* bridge-monitor: Mobula EIP-712 signature-confirm flow (fixes stuck deposits) (#1160)
* bridge-monitor: implement Mobula EIP-712 signature-confirm flow (fix stuck deposits)
* mobula sig: inject EIP712Domain type definition, API omits it
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081)
* subsidy is a chain KPI, not a bench: kill standalone spec, add 3 cards to /chains/robinhood strip
* pm-api-latency: add Predexon (bench 6th provider), fresh key deployed on the 3 Railway probe regions
* usdy: 3-retry on Orca RPC to fix 40% success rate (#1168)
publicnode.com burst-limits the VPS on getAccountInfo, dropping
~60% of samples. Two retries with 500ms backoff bring hit rate to
>95% without switching endpoints. Also add commitment=confirmed
to reduce slot-miss null returns.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* bench vague 2: ship 081-085 (ws latency, oracle freshness, rpc reliability, indexer latency, block builders) (#1170)
* add bench 081 ws-head-latency: WS block-push lag harness + spec
* add bench 084 indexer-latency: HyperSync vs The Graph vs Alchemy time-to-queryable
* idx-latency: only classify error when every poll errored, else timeout
* bump bench-set cache keys for 084 (lockstep rule)
* add bench 082 oracle-freshness: staleness metrics in oracle-deviation harness (multi-chain chainlink, pyth publish_time fix, redstone), bump bench-set cache keys
* oracle-freshness: count unsupported mainnet feeds in freshness error counter
* add bench 085 evm-block-builders: ETH builder share via extraData + relay bidtraces + Arb/Base soft-conf lag, bump bench-set cache keys
* ebb: don't count historical relay payloads on first poll, retry arb calibration on zero support, reset WS backoff after stable session
* add bench 083 rpc-reliability: consensus lag, hash quorum, fixed-vector integrity in rpc-capabilities harness, bump bench-set cache keys
* bench 083: clamp_min rank_matrix_query so zero-incident providers keep cell ranks
* gate benches 081-085 behind staging until 48h clean data
* drop shipped RWA slugs from prod gate (live on main since #1162/#1167)
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hyperliquid frontend page: add ProviderLogo in the hero next to the name
* hl frontends: add slash, topdog, markets-mobile logos + registry entries; dark-mode chip for slash + markets-mobile
* idx-latency: swap Alchemy for Mobula in the cohort (paid-plan key, bench races $0 pipelines only) (#1176)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix(bench): sample-size PromQL + leader reliability floor (#1175)
* fix(bench): sample-size PromQL + leader reliability floor
Two bench-correctness fixes surfaced by an audit of the oracle and
gas surfaces we plan to cite in outreach.
Bench 025 (oracle-deviation):
- sample_size was 'count(count by (source) (ocb_oracle_price))'
which returns the count of distinct sources (3 or 4), not the
count of samples over the window. The citable API therefore
reported sampleSize=3-4 on every pair, at odds with the spec's
'polled every 30s' claim. Rewrites the query to
'sum(count_over_time(ocb_oracle_price[24h]))' across all 10
pair entries so the number reflects actual poll volume.
Leader ranking:
- citationCandidates() now excludes providers with a measured
success rate below 50 percent from the leader pool. Before this
fix, gas-estimation showed Owlracle as #1 with successRate=6.48
and a p50 gap of 0.001 gwei: technically accurate on the rare
successful poll but useless as a leader claim. Falls back to the
full live pool when every provider is under the floor so a
totally degraded bench still surfaces a best-of-bad-options
leader rather than vanishing. Freshness benches without a
success query default to 100 percent in the loader so the guard
is inert there.
Adds src/lib/citation.test.ts with 5 tests covering the Owlracle
scenario, higher-is-better ranking, and the degraded-pool
fallback.
* fix(bench): propagate reliability floor to rankings + template + hub
Follow-up on the previous commit. Review flagged that filtering the
leader without filtering the ranking arrays leaves self-contradictory
JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle
on the same document.
Exports citationCandidates and adds rankedCandidates helper that
returns the same eligible pool sorted by the bench's higher_is_better
direction. leader() and fieldValue() now share it.
Wires the shared helper into every surface that emits a rankings-like
list or a best-of claim:
- src/app/api/stat/[slug]: rankings array now sorted from
rankedCandidates so rankings[0].slug always equals leader.slug on
the same JSON.
- src/app/api/llm-context: the Rankings numbered list under each
bench matches the Headline sentence above it.
- src/app/api/mcp/[transport]: get_benchmark tool response and the
openchainbench://benchmark/{slug} resource both share the pool.
- src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}}
tokens in bench copy resolve against the same pool so a bench body
never crowns a provider its own headline excludes. Per-slug tokens
{{p50:some-slug}} still resolve against the raw live pool so unknown
tokens still fall through.
- src/lib/providers: rankProviders (drives /products hub wins count
and per-chain leadership chips) now runs on the reliable pool.
Adds a consistency test locking that rankedCandidates[0].slug always
matches leader().slug on the same bench. Total: 6 tests in
citation.test.ts, 95/95 pass across src/.
Uses (r.successRate ?? 100) for the numeric guard to future-proof
against ProviderResult shapes that skip the field. Load path still
defaults to 100 (materialize/load.ts:748) so this is inert today, but
the extra ?? removes the fragility.
/api/compare/[a]/[b] is intentionally left unfiltered: the user
picks the two providers explicitly, so the head-to-head is a
requested comparison rather than a leader claim. Applying the floor
there would drop rows the user explicitly asked to see.
* fix(hub-card): honor citation leader in the hub grid headline
Follow-up review flagged that benchmark-card.tsx re-sorted b.results
locally and picked results[0], so a bench where citation demotes the
raw-p50 winner (e.g. Owlracle on gas-estimation) would show the
demoted provider on the hub grid while the bench page names the
correct leader. Two documents contradicting each other in one click.
Adds leaderSlug: string | null to BenchmarkCardData and populates it
at the projection boundary via leader(b), which already applies the
reliability + insufficient-sample filter. The card picks the leader
by matching that slug in the projected results, and falls back to the
raw best when the projection returned no leader (draft, insufficient,
or all providers filtered out) so a card still renders a headline
value in every state.
Kept the local sort so the fallback path and any downstream chip
using sorted[0].name keeps working.
Test suite unchanged: 95/95 pass across src/. No test needed since
this is a projection-boundary wire-up.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix(registry): remove duplicate topdog entry (#1177)
Commit 349b786 (hl frontends: add slash, topdog, markets-mobile logos +
registry entries) added a 'topdog' entry at line 313 without noticing a
pre-existing 'topdog' entry at line 1061, breaking staging build with a
duplicate object literal type error.
Keeps the newer entry (Telegram bot at t.me/topdog_trade_bot, HL
frontend context) since it was the one intentionally added for the
Hyperliquid frontends leaderboard chip work. Drops the older Discord
signal bot entry (topdog.gg): the bench spec at
benchmarks/hyperliquid-frontends.yml has slug: topdog with name TopDog
and matches the newer, HL-native product.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* vague2 QA: unit sec for true-seconds benches (fixes 0.0s heros), soften 080 findings, drop dead 085 builder rows, 083 expected_n 40 (#1178)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster (#1179)
Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).
Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
number, returns parentHash as the observed hash. Same latency
measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
polkadotStaleBlockGap = 40. Consensus observation is skipped
because chain_getHeader returns parentHash (N-1), not the current
block hash (deriving current hash needs Blake2 over the SCALE-
encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
Polkadot state accesses via state_getStorage keyed by Blake2 storage
keys, no chain-agnostic depth analog for eth_getBalance-by-depth.
Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
Substrate keyless)
Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.
Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
rpc-capabilities benchmark, chain cluster page and hub card
render it identically.
- Methodology documents the Substrate specifics: probe method
choice, staleness scaling, why no archive-depth or hash-quorum
columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
/ {{p50:slug}} template surface, consistent with other -rpc
benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
Foundation grantee status so the leaderboard reads correctly
for someone who knows the Polkadot infra landscape.
Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
citation reliability floor and consistency tests added in #1175.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* product pages: 9 block-builder registry entries + verified logos, oracle logos (pyth/redstone) (#1181)
* products: registry entries + logos for bench 085 block builders
* oracle logos: redstone official logo + pyth/redstone manifest + registry entries
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* 085: add bobthebuilder provider row (real share, was metrics-only) (#1182)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184)
* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster
Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).
Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
number, returns parentHash as the observed hash. Same latency
measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
polkadotStaleBlockGap = 40. Consensus observation is skipped
because chain_getHeader returns parentHash (N-1), not the current
block hash (deriving current hash needs Blake2 over the SCALE-
encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
Polkadot state accesses via state_getStorage keyed by Blake2 storage
keys, no chain-agnostic depth analog for eth_getBalance-by-depth.
Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
Substrate keyless)
Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.
Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
rpc-capabilities benchmark, chain cluster page and hub card
render it identically.
- Methodology documents the Substrate specifics: probe method
choice, staleness scaling, why no archive-depth or hash-quorum
columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
/ {{p50:slug}} template surface, consistent with other -rpc
benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
Foundation grantee status so the leaderboard reads correctly
for someone who knows the Polkadot infra landscape.
Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
citation reliability floor and consistency tests added in #1175.
* polkadot page + parity/onfinality/dwellir products
Wires the /chains/polkadot hub page, provider profiles, and logo
assets that surface once bench 086 polkadot-rpc starts feeding Prom.
- chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT).
Auto-populates /chains/polkadot once benches referencing chain="polkadot"
land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables
when the chain-kpis harness starts sourcing Polkadot alongside the
other L1s.
- provider-registry.ts: three new entries for the Polkadot cohort.
* parity: brand entry for Parity Technologies (Polkadot / Substrate
implementation shop, operator of rpc.polkadot.io).
* polkadot-official: endpoint-level entry describing the reference
rpc.polkadot.io keyless endpoint; providers.ts aliases the slug
to parity so the /products page collapses under the operator's
brand.
* dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless
Substrate infra) — excluded from the 086 launch cohort due to a
503 during the audit sweep but kept in the registry for the
revisit and for other places their brand might appear.
- providers.ts: add polkadot-official → parity alias and canonical
brand casings for polkadot / parity / onfinality / dwellir so the
/products hub, badges, and OG images render the correct name
without the title-case fallback butchering them.
- logo-manifest.ts: polkadot.png (chain hero), parity.png (product page
logo), plus polkadot-official → parity slug alias so the endpoint
chip on the bench page picks up Parity's mark automatically.
95/95 pass on bun test src/. Local build clean.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* store: last-known-good snapshot fallback (no more awaiting after first publish) (#1188)
publishSnapshot writes an lkg copy without TTL on every success;
readMaterialized falls back to it when the pointer/blob pair misses
(worker outage past blob TTL, DEL race, transient). dataAsOf keeps the
freshness badge honest about age. LKG write is best-effort and never
fails the publish.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* chain-kpis: add polkadot to registry (DOT native, stables via Asset Hub) (#1191)
Ships live KPIs on /chains/polkadot. DOT price and mcap flow through
Mobula. Stables card renders via DefiLlama Asset Hub aggregate.
TVL and DEX gauges stay unpublished (relay chain has zero DeFi:
parachain TVL lives under Acala, Moonbeam, Hydration).
Also guards defillama TVL publish to skip zero values so chains that
DefiLlama tracks by name but with an all zero series (Polkadot today)
hide the TVL card instead of rendering $0.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* live-feed status: Feed down pill + banner on aggregator-head-lag (#1193)
Short-window changes() probe per provider surfaces silent live feeds
that the 24h quantile aggregate hides (Codex WS blackout 08:23-09:04
UTC today moved the p50 by nothing but the whole feed was mute for
40min). Bench-level probe_ok gate suppresses badges when our harness
side is broken so a Railway crash cannot fake-flag every provider.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* series gaps: null the chart line when a feed goes silent (unless changes==0) (#1195)
Companion to feed-down banner: makes the 40min Codex blackout from
this morning (08:23-09:04 UTC) visible on the 24h chart instead of a
flat line covering the outage. Per-series match preserves the
aggregate line as long as one chain still receives events.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* add bench 087 ws-head-latency-base + 088 ws-head-latency-solana (#1196)
Per-chain siblings of bench 081 for the WebSocket head-lag cluster.
Same harness (harnesses/ws-head-latency), same race scoring, one
spec per chain to avoid mixing block cadences (Ethereum 12s vs Base
2s vs Solana 400ms) in a single leaderboard.
Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does
not expose a keyless Base WebSocket path.
Solana cohort: PublicNode + Solana Foundation. slotSubscribe
replaces newHeads on the harness side; metric names are shared so
the queries are structurally identical to the EVM specs.
Data already flows via the Paris VPS deployment (chain label carries
base and solana). No harness change needed for this ship. Multi-region
is a follow-up on the parent bench.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: multi-region (harness region label + spec region tabs) (#1198)
* ws-head-latency: region label via ConstLabels + Railway config
Adds a static region ConstLabel to every ws-head-latency metric,
bound at process startup from OCB_REGION (preferred) or
RAILWAY_REPLICA_REGION (Railway auto-set), with eu-west fallback
matching the historical single-vantage Paris VPS deployment.
No observation site needs to change: the label is baked into every
counter, gauge and histogram at registration, so all existing
WithLabelValues(provider, chain) calls stay identical.
Adds railway.toml so Railway can build the harness from Dockerfile
with a /health check, matching the rpc-capabilities service layout.
Follow-up: create the Railway ws-head-latency service with 3
replicas (us-east, eu-west, sgp) and OCB_REGION set on each, then
decommission or leave the Paris VPS container as a 4th vantage.
* ws-head-latency specs: add region dimension tabs (081 + 087 + 088)
Adds `dimensions.region` block + `rank_matrix_query` on provider,
region + per-provider `regions:` sub-arrays with region-scoped
queries on the 3 ws-head-latency specs, following the polkadot-rpc
pattern.
Depends on the harness region label change (previous commit): every
metric now carries a region ConstLabel so the region-scoped
histogram queries return values as soon as multi-region replicas
publish. Until Railway 3-replica deploy lands, only region=eu-west
has data and the other tabs render empty; the All regions tab keeps
working because the aggregate query is unscoped by region.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: align region env var with rpc-capabilities (REGION not OCB_REGION) (#1200) (#1201)
Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* chart: red band overlay for null-bucket downtime windows (#1203)
Companion to #1195 (series null gaps). The gaps themselves render as
line breaks but a break can read as 'the chart missed a sample'. A
soft red column behind the break spans the exact outage window and
makes the reader understand it was a real feed-side silence. Per
line + per contiguous gap range, extended by one step so 1-bucket
outages stay visible instead of collapsing to a zero-width sliver.
Hidden for excluded (legend-toggled) providers.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* rename ws-head-latency -> ws-head-latency-ethereum for sibling slug parity (#1204)
081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve).
Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* network-coverage: drop coinstats (free tier credits exhausted) (#1206)
CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned.
Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API).
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* Revert "network-coverage: drop coinstats (free tier credits exhausted) (#1206)" (#1208)
This reverts commit d295f7168da981078164119e508ca9546ed69fa2.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime bands: provider-colored tint + name/silent label + edge markers (#1210)
Feedback on #1203: the neutral red band read as 'something happened
here' without telling the reader WHO. Tint each band in the
provider's own color, add dashed edges at the outage boundaries, and
label 'PROVIDER · SILENT' above the band when it fits (with a caret
that survives on narrow single-bucket bands). Works uniformly for
Mobula/Codex/Gecko or any future provider with a live_activity
query.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime bands: pill labels + vertical stacking on overlap (#1211)
Screenshot review: the small text-only label above the band was still
too subtle to read as 'this provider is down', and 3 simultaneous
provider outages produced overlapping unreadable labels stacked on
the same y. Replace with high-contrast rounded pills (white text on
provider color) that stack vertically when their X-ranges overlap.
Renders as '● NAME DOWN' in bold small caps so a reader gets the
event and the provider identity at a glance.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tsp-arb-latency: lower trigger 50 -> 25 bps + credit sub-poll closures (#1212)
Bench 080 published only 7 samples across 3 of 11 assets in 24h at
50 bps trigger. Two contributing causes fixed together:
1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on
Nasdaq (earnings, macro prints), not a steady-state event. 25 bps
is above normal intra-minute noise on liquid stocks and captures
real arb-worthy moves without collapsing the semantic.
2) Sub-poll convergence credited. When the reference moves past the
trigger AND the pool is already within band on the same tick, the
arb closed inside one poll interval. Previously dropped silently;
now observed at pollInterval (60s) so the fast-arb tail on liquid
pools stops disappearing from the histogram.
Convergence threshold stays at 20 bps: the "how close is the pool to
fair" question is unchanged, only trigger sensitivity moves. Spec
text (subtitle, methodology, FAQ) updated to 25 bps throughout.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime pill: drop the leading dot indicator, center the label (#1213)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime pill: 'DATA MISSING' wording + skip leading gaps (#1215)
Two fixes based on 30D view review:
- Leading gaps (Prom retention shorter than the visible window,
harness started mid-range, provider added recently) rendered as
a chart-wide band claiming every provider was down for the
missing period. Skip runs that started before the first observed
sample.
- Rename 'DOWN' to 'DATA MISSING'. A silent series can be the
provider going down, our harness losing its WebSocket, or a Prom
scrape failure — the pill stays neutral instead of blaming the
provider.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* drop bench 084 indexer-latency (harness pruned, spec deleted) (#1218)
HyperSync and The Graph need paid keys for the sustained cadence the
bench expects; Mobula alone left the leaderboard single-provider and
the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to
drop the whole cluster than to buy 2 SaaS subscriptions to prop up
a bench nobody has cited yet.
Removed:
* benchmarks/indexer-latency.yml
* harnesses/indexer-latency/ (whole directory)
Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns
Gone rather than 404. Cache keys bumped so cached v30/v33 entries
that still enumerate the slug age out on next revalidate.
VPS follow-up (separate ops): stop ocb-indexer-latency container,
remove from docker-compose.yml + prometheus.yml scrape config.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* video/render: strict Zod body schema before upstream forward (#1216)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* mcp: allowlist perp_funding_*, perp_venue_*, perp_execution_* prefixes (#1220)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: drop 'unless changes==0' from series queries (#1221)
Bug: the guard was designed for modern 15s-cadence data, where
changes([15m]) works cleanly. On backfilled historical data compacted
to ~1 sample/hour, changes([15m]) returns 0 even though the gauge is
healthy — the 30D view lost the first 8 days of data to false-drops
(all three providers marked DATA MISSING for a whole week).
Real-time silence is still surfaced by the live_activity scalar
(pill + banner + downtime band), which runs against fresh scrape
cadence and stays accurate. The chart series now shows every point
Prom has, flat during old outages instead of gapped.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* seo: 200 placeholders + sitemap threshold buffer (#1222)
- badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data
- hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder
- compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate
Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: restore outage detection with backfill-safe guard (#1224)
Follow-up to #1221. Dropping the guard entirely restored the 30D
range but silently hid today's Codex morning blackout on the 24H
view (frozen gauge value = flat line, indistinguishable from a
healthy quiet period).
Bring the guard back, gated on count_over_time > 5 samples in the
window. Modern data at 15s cadence carries ~60 samples per 15 min
so the gate fires the moment a WS goes silent. Backfilled data
compacted to ~1 sample/hour has count <= 2 in a 15m window so the
gate stays open and every point Prom kept is served — no more fake
wall-to-wall gap at the start of the 30D range.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: sub-ms histogram floor + keyed slots for base/solana (#1225)
Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a
[0,5ms] first bucket interpolates every winner's p50 to ~2.5ms
(chain-independent). Replace with a 0.5ms floor climbing to 10s so
sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s).
Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode)
and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can
extend beyond keyless once contributor keys are wired. Keyless tier
is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC,
BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack-
public — none accepted a keyless WSS handshake as of 2026-07).
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ship: ungate bench 085 evm-block-builders on prod (#1227)
Titan leads with sample=6973 in 24h, data healthy since the harness
ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod
next revalidate. Cache keys bumped to bench-unfiltered-v32 +
all-benchmarks-v35 so cached entries do not keep dropping the slug
via the old prod gate.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* loader: propagate 24h series null-runs to 7d/30d (#1229)
Fine-grained outages (24h step = 20 min buckets) rarely align with
coarser evaluation timestamps (7d step = 2h, 30d step = 12h) so the
Prom query returns a healthy average and the reader sees the pill on
24H but nothing on 7D/30D. Post-process: for every contiguous null
run detected on s24, translate the time-ago range to bucket indices
on s7 and s30 and null every coarse bucket the run straddles.
Contiguity preserved so a single short outage doesn't split into two
adjacent pills after propagation. Same treatment applied to per-region
series.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* propagateNullsToCoarser: use absolute time-ago instead of window fraction (#1230)
Bug: the previous math scaled by the fine window fraction, which
projected a 7h-ago outage down to 40h-ago on the 7d grid (the pill
landed at -1.7d instead of -6h). Both grids share 'now' as their
right anchor and only the step sizes differ — take the fine idx's
absolute time-ago and divide by the coarse step to find the
covering bucket.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL AMZN MSFT SPY) (#1231)
Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* perp-fees harness: fix Paradex depth-skip, gains SOL slot, dYdX walk order, GMX Subsquid error surfacing (#1233)
- Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values.
- gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL.
- dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers.
- gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path.
Verified: go build + go vet clean.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime bands: opt-in via live_activity, skip on other benches (#1235)
Screenshot review: rpc-reliability rendered a 'LAVANODIES DATA MISSING'
pill even though its spec never declared live_activity — natural
nulls in its series24h (sparse scrapes, backfill edges) triggered
the DowntimeBands render. Gate the render on
benchmark.results.some(r => r.liveStatus != null), which is only set
when the spec declared live_activity. Same gate on
propagateNullsToCoarser so other benches' 7d/30d series stay
untouched.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* batch 2: MEV chips + rpc copy + Aster/edgeX + Cardano/Sui/Cosmos/Avax (#1236)
* site: provider_notes chip on ranking rows + rpc-capabilities copy fixes
- validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat
- New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render)
- Chip renders only when the note is set, no visual change to any other bench
- rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ
- rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST)
* perp-fees harness: add Aster + edgeX venues
- Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API)
- edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0)
- Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers
- Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume)
* validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche
Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains.
- Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io.
- Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs.
- Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission).
- Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100).
Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* rpc-capabilities: add 4 keyless providers (ThirdWeb Blast Gateway.fm bloXroute) (#1238)
Broadens the keyless cohort for benches 010 (rpc-capabilities), 083
(rpc-reliability) and the per-chain RPC benches. All 4 providers
verified live returning valid eth_getBlockByNumber on their
respective chains before inclusion.
New (provider, chain) cells:
ThirdWeb 8 chains (ETH POL ARB OP BASE BSC AVAX LINEA)
Blast API 4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier
bloXroute 3 chains (ETH BASE BSC)
Gateway.fm 1 chain (ETH)
Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix
groups by provider+chain) and the per-chain leaderboards after the
Railway rebuild picks up main.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: add Onfinality Ethereum to keyless cohort (#1241)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* drop oracle-freshness (082) spec + sync REMOVED with main state (#1245)
Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal.
Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader).
Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state.
Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* SEO/E-E-A-T pack: /team page, Person schema, 23 positioning, 8 expert_take (#1249)
Adds a broad SEO and GEO upgrade landing three surfaces at once:
- /team page with Florent Tapponnier as Maintainer, sameAs GitHub +
LinkedIn, Person JSON-LD wired into Organization.founder and into
every bench StatisticalReport.contributor + TechArticle.author +
reviewedBy so YMYL crypto queries see a named human accountable.
- 23 positioning fields on alternatives specs (200 words each,
factual, sourced from official docs, fact-checked via WebFetch).
Cuts the shared-template surface from ~65 percent to ~30 percent
across pages that reference the same benchmark.
- 8 expert_take fields on answers specs (150-200 words each,
structural commentary that stays valid regardless of who currently
leads the leaderboard) with QAPage schema so the acceptedAnswer
(Org, auto) and suggestedAnswer (Person, expert_take) both reach
Google + AI Overviews.
- Byline on answers pages when expert_take is present, linking to
/team, hidden when dataPending so editorial context never renders
next to an empty leaderboard.
- noindex on awaiting benches so Prom outages do not surface thin
placeholder pages to crawlers.
- Optional Zod fields positioning + expert_take + expert_take_reviewed
with noAiDashes refine so future drafts stay in policy.
- Sitemap prebuild script emits real git-commit timestamps for the
8 editorial pages so lastmod stops resetting to build time on every
unrelated Vercel deploy.
- scripts/editorial-check.sh lints the two new fields for em/en
dashes, marketing adjectives, pronouns, year references.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* alternatives: render positioning + clean paragraph line-wrap (#1250)
Two fixes on top of #1249:
1. Add a Positioning block on /alternatives/[slug]/page.tsx that
renders the newly-added field. Phase 1 added the Zod schema
field but never wired the UI, so the content sat in YAMLs
invisible on staging.
2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
single line per paragraph. Previous version hard-wrapped at
180 chars, which YAML literal (|) preserves as newlines,
splitting words mid-token in the rendered HTML.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hotfix: strip glued expert_take_reviewed from status line (#1251)
* alternatives: render positioning + clean paragraph line-wrap
Two fixes on top of #1249:
1. Add a Positioning block on /alternatives/[slug]/page.tsx that
renders the newly-added field. Phase 1 added the Zod schema
field but never wired the UI, so the content sat in YAMLs
invisible on staging.
2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
single line per paragraph. Previous version hard-wrapped at
180 chars, which YAML literal (|) preserves as newlines,
splitting words mid-token in the rendered HTML.
* fix: strip glued expert_take_reviewed from status line
The previous rewrite regex dropped the newline before existing
expert_take_reviewed values without adding a proper break, so
'status: live' and 'expert_take_reviewed: ...' ended up on the
same line, failing YAML parse and killing the build export step.
Also collapsed duplicate expert_take_reviewed lines that landed
during the double-run of the rewrite script.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hotfix: strip glued status line in 8 answer YAMLs (#1252)
Fix that landed in a prior branch was lost during a squash-merge
sequence. Reapplying directly off dev to guarantee it lands.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* answers: drop visible byline on expert_take blocks (#1253)
The visible "By Florent Tapponnier, reviewed <date>" byline
implied authorship of every expert_take paragraph and dated the
review with a fabricated timestamp, both editorially fragile.
Keep the E-E-A-T signal at the schema layer only: Person JSON-LD
on Organization.founder, StatisticalReport.contributor, TechArticle
author + reviewedBy, and QAPage suggestedAnswer.author. Search
engines see the human attribution where it counts; readers see the
editorial commentary without a personal claim on every crypto
opinion the site publishes. Removes the unused Byline component too.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* drop bench 080 tokenized-stock-arb-latency (Rob Chain pools too illiquid) (#1254)
5 of 11 tickers had frozen pool prices (no swap activity). Remaining 6
had p99 pinned at the 32min histogram cap because arbs rarely close
within the settle window. Robinhood Chain launched Mar 2026, tokenized
equity market still too young for a meaningful arb-latency bench.
Removed:
* benchmarks/tokenized-stock-arb-latency.yml
* harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
* arb_tracker calls + closeStaleOnStateChange in main.go
Kept: bench 077 tokenized-stock-peg + 079 weekend-drift (static peg
measurements) stay live on prod as the honest signal for this cluster.
Slug in REMOVED_BENCH_SLUGS for 410 Gone. Cache keys bumped.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* jsonld: add 'name' to TechArticle nodes for Google Rich Results validation (#1257)
GSC flagged 'Missing field name' on aggregator-head-lag JSON-LD (and by extension every bench slug page + every per-chain page + the methodology hub — all TechArticle nodes shipped only 'headline'). Schema.org Article inherits 'name' from CreativeWork; Google's Rich Results validator treats it as required even when 'headline' is present. Add 'name' alongside 'headline' with the same value on all three affected builders.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-card: propagate region/kind/venue filters + compose dynamic title (#1260)
Feedback from Maxime: exported images do not adapt to the active
filters on the bench page. A share-card exported from
/benchmarks/rpc-capabilities?chain=ethereum®ion=sgp used to render
just 'Fastest free public RPC ...' with cross-chain data; now the
loader picks up the region filter and the title reads 'Fastest free
public RPC ... Ethereum · Singapore' with the correctly scoped data.
Change:
* share-card route reads region, kind, venue URL params (symmetric
with the existing chain param). Each maps to a spec dimension and
forwards to the materialize loader so the data is filtered too,
not just the title.
* A composed contextParts string is built from the resolved dimension
labels and appended to the aggregate title. Empty when no filter
is active, no trailing separator on the default view.
* The composed title is overlaid onto the benchmark passed to each
render (spread with {...b, title: displayTitle}) so all 5 templates
(ranking, leaderboard, snapshot, headline, compare) pick it up
without any render-signature changes.
* share-section + share-section-modal read live URL params for
region/kind/venue in addition to chain when composing the card URL,
so a modal opened from a scoped bench page passes the same scope
to the exported PNG.
Follow-up (not in this PR): view / range / topN selectors, per-bench
UI in the modal to expose the dimension pickers explicitly. This PR
is the plumbing; the modal UX for exposing new pickers is next.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)
This reverts commit b0ce3ef564fe4ca2aca905245c0d4dd1c6373054.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* Revert "Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)" (#1263)
This reverts commit d7f0fcbbbb6ad0db967c285fd23f99ca76b066f5.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: peer-activity clause (on/chain,region) fixes pool-silent false positives (#1265)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-card: filter negative p50 in sortByP50 (fixes HTTP 500 on RPC benches) (#1266)
Commit c03c599 relaxed the sortByP50 filter from `p50 > 0` to `p50 !== 0` to support the negative-p50 'warming up' sentinel introduced for funding benches on the /products page. The relaxation leaked into the share-card render path, where negatives crash Satori: negative p50 values fed to Math.max produce a negative maxP50, which inverts every bar height ratio and blows up the JSX with an opaque 'Spread syntax' error mid-render.
Symptoms observed: /benchmarks/{rpc-capabilities,polkadot-rpc,ws-head-latency-ethereum,evm-block-builders}/share-card?template=ranking all returning HTTP 500. Working benches (network-coverage, perp-fees, oracle-deviation) had no negative sentinel values in their data.
Fix restores `p50 > 0` in the share-card filter. The negative sentinel handling stays on the /products page where it was designed to render (as 'warming up'). Share-card treats negatives as noise and drops them, same as zeros.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-modal: explicit dimension pickers (chain / region / kind / venue) (#1267)
Feedback from Maxime: user needs to be able to pick chain/region/kind/venue from within the share modal, not just inherit whatever the URL had when opening. Also the composed card title must reflect the pick in real-time.
Adds a DimensionRow chip component rendered above the template tabs. One row per dimension the bench declares (chainDims / regionDims / kindDims / venueDims). Each row: 'Label' + 'All' chip + one chip per option. Clicking a chip toggles state; clicking the active pick clears back to All.
State is seeded from the live URL on mount so the modal opened from a scoped bench page starts on the same scope (unchanged behaviour). User can override in-modal by clicking a different chip; state updates re-render the card preview with the new filter forwarded to the loader.
The cardSrc URL builder now reads from picker state instead of the live URL - previous implementation re-read the URL every render which meant the modal's own state changes couldn't propagate. Same URL param names on the share-card route: no server-side change needed.
Bench without dimensions: block hidden entirely, modal looks unchanged.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: ws_connected guard filters out harness-reconnect false positives (#1269)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hotfix(share-card): revert region/kind/venue passthrough + title overlay (#1270)
The region/kind/venue param plumbing (added via #1260 → reverted via
#1261 → re-added via #1263) and the display-title overlay via
{...b, title: displayTitle} still cause HTTP 500 on rpc-capabilities
share-card and other benches with dimensions. My sortByP50 fix
(#1266) turned out not to be the actual root cause; the runtime error
persists after that fix + the modal picker PR (#1267).
Reverts the route.tsx changes only:
- Restore original getBenchmark({chain}) call (no region/kind/venue
forwarding)
- Restore original switch that passes plain benchmark/filteredSafe
to renders (no {...b, title: displayTitle} spread)
- chainLabel still passed through (unchanged from pre-Maxime-feedback)
Keeps the modal DimensionRow pickers (#1267) intact for a future
retry once the true root cause is found. They currently look for
matching URL params but the server-side won't act on them until the
route.tsx re-adds forwarding safely.
Bug still open on rpc-capabilities/polkadot-rpc/ws-head-latency-*/
evm-block-builders share-cards, but now user-visible functionality
(chain filter, existing templates on non-broken benches) is fully
restored to pre-Maxime-feedback behaviour.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: strip ws_connected clause from geckoterminal (REST-only, no ws metric) (#1272)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-card: fix HTTP 500 on RPC benches (Satori AVIF/WebP + CSS var crashes) (#1273)
Two independent bugs causing pre-existing HTTP 500 on rpc-capabilities,
polkadot-rpc, ws-head-latency-*, evm-block-builders share-card
endpoints. Reproduced locally with bun dev + shimmed try/catch to
unmask the mid-stream Satori errors.
Bug 1: Satori (the JSX-to-SVG renderer next/og uses) only decodes
PNG / JPEG / SVG. Feed it AVIF or WebP bytes and it throws
'TypeError: u2 is not iterable' mid-render, surfacing as opaque
HTTP 500. publicnode.avif, drpc.webp, lava.webp all trigger this on
the RPC benches. Fix: skip AVIF/WebP extensions in
getProviderLogoDataUrl, fall back to the initials chip.
Bug 2: chipBackground / chipTextColor from src/lib/brand.ts return
CSS var references (e.g. 'var(--color-ink-soft)') when the provider
has no registered brand color. Satori evaluates var(...) to the
CSS-wide 'initial' keyword and rejects 'background: initial',
crashing the whole PNG. evm-block-builders is a full-cohort case
(Titan, BuilderNet, Quasar, Eureka, Builder+, Vanilla — none have
brand colors or logos). Fix: resolve var(...) fallbacks to the
concrete INK_SOFT / PAPER …
Flotapponnier
added a commit
that referenced
this pull request
Jul 25, 2026
* fix(tqc): em dashes + ship to prod (#1420)
* solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries
* nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* nft harness: delist rarible, free tier is 100 requests per month (#1137)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees
* bridge-monitor: on-chain balance fallback + degraded flag
* bridge-monitor: self-healing metrics + slack notifiers
* bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge
* bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight
* bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer
* bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default
* bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset
* bridge-monitor: keyless dry-run support for reaper + RunDryRun
* bridge-monitor: comment style cleanup in pre-flight docs
* bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive
DailySpentUSD only ever incremented and was zeroed by every restart, so a
crash-loop minted a fresh budget each time. The tracker resets exactly at
UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default
./spend-state.json, not /tmp) and is mutex-guarded. Also declares the
package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD
(default 0.50) helper for gas bled by failed broadcasts.
* bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock
Fund-loss fixes from hostile review:
- a corrective transfer whose deposit broadcast but whose bridge status
never resolved is now TERMINAL for the scheduler slot (funds are likely
still in flight, a retry double-sends); only pre-broadcast failures may
consume another attempt. executeOnBridge keeps TxHash on error paths so
callers can tell the two apart.
- ONE corrective-transfer budget (max 2) per scheduler slot, shared across
all downgrade-ladder rungs (was per rung: up to 6 transfers per slot).
- package-level execution mutex: scheduler slots own the wallets end to
end, the reaper TryLocks and skips its tick instead of queueing.
- broadcasts that failed or timed out after getting a TxHash book a flat
FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding
unaccounted gas; all spend goes through the persisted UTC-day tracker.
- gas top-up now honors MaxDailySpendUSD before broadcasting.
* bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode
The reaper (and any other consumer) used to accept simulated balances even
in production with broadcast-capable keys, so phantom numbers could
green-light real transfers. SimulateBalances now takes the execution mode
and returns nil outside dry-run; startup logs a warning when the flag is
set in a broadcast-capable mode.
* tx executor: preserve hash on ambiguous send errors, split dry-run spend state
* executor: add missing chain label, first real single-test panicked post-fill (#1141)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated)
* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tsp: parse the flat spark shape (close/timestamp arrays), log empty parses
* tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes
* rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages
* rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family
* rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions
* registry: googl links to google.com, abc.xyz 403s aggressively
* cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves
* fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152)
* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident)
* bridge-monitor: sub-50ms buckets + nanosecond precision on Observe
Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000]
to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents
on solver-cached corridors (HyperCore in particular) returns via a
1Click coordinator fast path in <50ms, and the previous scheme lumped
every one of those observations into (0, 50] so histogram_quantile
linearly interpolated to ~25ms regardless of true value. The p50 shown
on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a
bucket floor, not a measurement. Sub-50 buckets let the real bimodality
surface.
Also swap Observe(float64(quoteLatency.Milliseconds())) for
Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6
per-bridge observers (across, debridge, lifi, mobula, nearintents,
relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0,
same bug class as the RPC harness fix in PR #1128. Not a hot path here
(network RTT dominates), but keeps precision consistent with the new
low buckets and cheap to fix while we are already touching each file.
Bench YAML methodology + FAQ updated to reflect the new bucket set.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154)
* fix: 3 machine-readable surface bugs surfaced by GEO audit
1. /api/citable/{date} unit scaling.
Snapshot route emitted `value: 627` for `aggregator-head-lag` while the
live /api/citable emitted `value: 0.63` on the same measurement. The
live route already wraps with valueInDeclaredUnit(raw, b.unit) but the
snapshot route was left with the raw internal-ms value. Wrap the same
way; sub-1 s benches now render in the declared `s` unit on both routes.
2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=.
getBenchmark accepted a filters object but the route never parsed the
URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the
cross-chain aggregate. Parse the four dimension params, pass through
to the loader; unknown values fall back to unfiltered inside the
loader. Also widen getBenchmark options type to include kind and
venue — the underlying loadBenchmark already supports both.
3. Draft benches leaked a fake freshness signal.
Draft placeholder set lastRunAt to new Date for type safety
(Benchmark.lastRunAt is non-null), and both citable routes passed it
straight through as asOf. LLM crawlers treating asOf as ground truth
would think a draft bench was measured every minute. Null asOf in the
JSON when status is draft; the internal type stays intact.
* fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified
The GEO audit review found the initial fix in commit f6f84b8 only covered
the two /api/citable JSON surfaces, but the same "draft benches spoof
freshness" class of bug lives on three more machine-readable channels
that LLM crawlers also key on:
- /api/stat/{slug} asOf field.
- MCP tool responses (list_benchmarks, get_benchmark, resource template,
and the plain-text "Last sample" line).
- JSON-LD dateModified on the bench page, per-chain sub-page, answer
page and alternatives page — Google, Bing, Perplexity all consume
dateModified as a freshness ranking signal.
Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every
call site. Draft benches now uniformly omit or null the freshness
timestamp; live benches unchanged. On the visible /answers page,
the "Data as of..." line and its <time dateTime> anchor also hide for
drafts rather than displaying the wall-clock placeholder.
Follow-up to PR #1154 (commit f6f84b8) covering the surfaces the review
agent flagged as still spoofing.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* feat: /api/compare/{a}/{b} + openapi.json coverage (Wave 1 P0/P1) (#1156)
* feat: /api/compare/{a}/{b} + openapi.json coverage
Two GEO surfaces flagged by the audit as high-value gaps.
## /api/compare/{a}/{b}
Head-to-head comparison as a single citable payload. LLMs asked "Mobula
vs Codex head lag" would otherwise need two /api/stat calls plus their
own delta reasoning; this endpoint returns one flat structure with per
benchmark p50 for each side, the winner on the metric's
higher-is-better convention, and the absolute delta.
The intersection logic mirrors src/lib/related-providers.ts and the
HTML /compare/{slug} page: shared benchmark = both providers appear in
it. Intentionally returns flat leader-p50 only (no chain/region
breakdowns, no cache-heavy fan out) so the agent tool call stays fast.
## openapi.json coverage
Added /api/citable/{date} (was missing despite being a real API
endpoint) and /api/compare/{a}/{b} to the OpenAPI 3.1 spec. Also
documented the dimension query params (chain/region/kind/venue) on
/api/stat/{slug} that shipped in the previous PR but were undocumented.
Added Compare schema; extended Stat schema with `filters` field.
/llms.txt and /llms-full.txt stay outside the OpenAPI surface — they
are text files not RPC endpoints, and OpenAPI consumers do not handle
plain-text specs well anyway.
## citableAsOf helper widened
Type signature relaxed from full Benchmark to
`Pick<Benchmark, "status" | "lastRunAt">` so the slim
ProviderAppearance.benchmark used by the new compare route can pass
through without a downcast. No behavior change on existing callers.
## Notes on items NOT shipped in this batch
- Cache-Control on /llms.txt / /api/citable / /api/llm-context: the
audit report showed `cache-control: public` and inferred no CDN
caching, but the source already sets
`public, s-maxage=300, stale-while-revalidate=900`. Vercel strips the
`s-maxage` directive from the client-visible header while honoring it
at the edge; `x-vercel-cache: HIT` with `age: 256` confirms the CDN
cache is active. Not a real bug.
- .bib / .ris file MIME endpoints: string versions already ship inside
the /api/stat JSON via cite.bibtex / cite.apa. Marginal upside for
Zotero-style one-click imports; downgraded to P2.
- /api/stat/{slug}/sparkline.svg: sparkline data already ships as a
float array in /api/stat and the OG image at /api/og/{slug} covers
the "embed a chart in an answer" case. P2.
- JSON-LD `Dataset.variableMeasured.value`: current schema emits bare
strings; upgrading to PropertyValue objects would duplicate the
measured value already carried by the StatisticalReport node's
Observation.measuredValue. P2.
* openapi: add totalWins to Compare schema
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* feat: Wave 1 P2 batch — cite MIME, sparkline SVG, Dataset PropertyValue (#1158)
* feat: Wave 1 P2 batch — .bib/.ris/apa MIME, sparkline SVG, Dataset PropertyValue
Three GEO surface additions the audit downgraded to P2. Each one is
duplicative with an existing channel (cite.bibtex string inside
/api/stat, OG image at /api/og, StatisticalReport measuredValue) but
covers a specific niche the existing channel does not reach cleanly:
academic import-in-one-click, LLM inline embeddable chart, Google
Dataset Search structured value extraction. Ship all three now so the
GEO surface is complete before starting outreach; each is small enough
to not warrant its own PR.
## /api/cite/{slug}/{format}
Per-bench citation with the correct MIME type. Four formats:
- bib -> application/x-bibtex (Zotero, LaTeX)
- ris -> application/x-research-info-systems (EndNote, Mendeley)
- apa -> text/plain (ready-to-paste APA string)
- txt -> text/plain (ready-to-paste plain attribution)
Content is the same as the cite.* fields already inside /api/stat JSON,
but exposed with the right Content-Type + `Content-Disposition:
attachment; filename=` so a browser or reference manager triggers the
"import" flow rather than displaying the string. Added RIS to CiteBundle
in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2
/ ER fields, CRLF line endings per RFC convention).
## /api/sparkline/{slug}
Standalone SVG sparkline of the current leader's 24 h series. Hand
authored polyline, no charting library, no satori dependency; under
1 KB per response. Query params: `w` (40..1200, default 240), `h`
(20..400, default 60), `theme=light|dark`. Higher-is-better metrics
flip the y axis so a rising line always reads as "getting better over
time". Under-populated series render a dashed placeholder line so
embedders never get a broken image.
Cases this reaches that /api/og and the raw JSON sparkline array do
not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to
posts, Notion pages. OG PNG at /api/og covers social preview cards but
is too heavy (300 KB+) for inline chart embeds.
## Dataset.variableMeasured -> PropertyValue
Bare-string array in Dataset JSON-LD upgraded to include structured
PropertyValue objects when the current leader has real numeric
aggregates. Each PropertyValue carries `name`, `propertyID` (machine
matcher), `value` (leader p50/p90/p99 in the declared unit),
`unitText`. sample_size stays a bare string — it labels an axis, not a
measurement. Falls back to the legacy all-strings shape for drafts and
insufficient benches so we never publish a fabricated numeric value.
New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts.
BenchDatasetInput.variableMeasured now accepts
Array<string | VariableMeasuredValue>. `keywords` extraction pulls the
`.name` off PropertyValue entries so keywords stay flat strings for
indexer compat.
## openapi.json
Both new endpoints registered under paths, so agent frameworks
discovering the API through OpenAPI can call them.
* sparkline: XML-escape bench title + provider name in SVG
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* bridge-monitor: Mobula EIP-712 signature-confirm flow (fixes stuck deposits) (#1160)
* bridge-monitor: implement Mobula EIP-712 signature-confirm flow (fix stuck deposits)
* mobula sig: inject EIP712Domain type definition, API omits it
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081)
* subsidy is a chain KPI, not a bench: kill standalone spec, add 3 cards to /chains/robinhood strip
* pm-api-latency: add Predexon (bench 6th provider), fresh key deployed on the 3 Railway probe regions
* usdy: 3-retry on Orca RPC to fix 40% success rate (#1168)
publicnode.com burst-limits the VPS on getAccountInfo, dropping
~60% of samples. Two retries with 500ms backoff bring hit rate to
>95% without switching endpoints. Also add commitment=confirmed
to reduce slot-miss null returns.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* bench vague 2: ship 081-085 (ws latency, oracle freshness, rpc reliability, indexer latency, block builders) (#1170)
* add bench 081 ws-head-latency: WS block-push lag harness + spec
* add bench 084 indexer-latency: HyperSync vs The Graph vs Alchemy time-to-queryable
* idx-latency: only classify error when every poll errored, else timeout
* bump bench-set cache keys for 084 (lockstep rule)
* add bench 082 oracle-freshness: staleness metrics in oracle-deviation harness (multi-chain chainlink, pyth publish_time fix, redstone), bump bench-set cache keys
* oracle-freshness: count unsupported mainnet feeds in freshness error counter
* add bench 085 evm-block-builders: ETH builder share via extraData + relay bidtraces + Arb/Base soft-conf lag, bump bench-set cache keys
* ebb: don't count historical relay payloads on first poll, retry arb calibration on zero support, reset WS backoff after stable session
* add bench 083 rpc-reliability: consensus lag, hash quorum, fixed-vector integrity in rpc-capabilities harness, bump bench-set cache keys
* bench 083: clamp_min rank_matrix_query so zero-incident providers keep cell ranks
* gate benches 081-085 behind staging until 48h clean data
* drop shipped RWA slugs from prod gate (live on main since #1162/#1167)
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hyperliquid frontend page: add ProviderLogo in the hero next to the name
* hl frontends: add slash, topdog, markets-mobile logos + registry entries; dark-mode chip for slash + markets-mobile
* idx-latency: swap Alchemy for Mobula in the cohort (paid-plan key, bench races $0 pipelines only) (#1176)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix(bench): sample-size PromQL + leader reliability floor (#1175)
* fix(bench): sample-size PromQL + leader reliability floor
Two bench-correctness fixes surfaced by an audit of the oracle and
gas surfaces we plan to cite in outreach.
Bench 025 (oracle-deviation):
- sample_size was 'count(count by (source) (ocb_oracle_price))'
which returns the count of distinct sources (3 or 4), not the
count of samples over the window. The citable API therefore
reported sampleSize=3-4 on every pair, at odds with the spec's
'polled every 30s' claim. Rewrites the query to
'sum(count_over_time(ocb_oracle_price[24h]))' across all 10
pair entries so the number reflects actual poll volume.
Leader ranking:
- citationCandidates() now excludes providers with a measured
success rate below 50 percent from the leader pool. Before this
fix, gas-estimation showed Owlracle as #1 with successRate=6.48
and a p50 gap of 0.001 gwei: technically accurate on the rare
successful poll but useless as a leader claim. Falls back to the
full live pool when every provider is under the floor so a
totally degraded bench still surfaces a best-of-bad-options
leader rather than vanishing. Freshness benches without a
success query default to 100 percent in the loader so the guard
is inert there.
Adds src/lib/citation.test.ts with 5 tests covering the Owlracle
scenario, higher-is-better ranking, and the degraded-pool
fallback.
* fix(bench): propagate reliability floor to rankings + template + hub
Follow-up on the previous commit. Review flagged that filtering the
leader without filtering the ranking arrays leaves self-contradictory
JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle
on the same document.
Exports citationCandidates and adds rankedCandidates helper that
returns the same eligible pool sorted by the bench's higher_is_better
direction. leader() and fieldValue() now share it.
Wires the shared helper into every surface that emits a rankings-like
list or a best-of claim:
- src/app/api/stat/[slug]: rankings array now sorted from
rankedCandidates so rankings[0].slug always equals leader.slug on
the same JSON.
- src/app/api/llm-context: the Rankings numbered list under each
bench matches the Headline sentence above it.
- src/app/api/mcp/[transport]: get_benchmark tool response and the
openchainbench://benchmark/{slug} resource both share the pool.
- src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}}
tokens in bench copy resolve against the same pool so a bench body
never crowns a provider its own headline excludes. Per-slug tokens
{{p50:some-slug}} still resolve against the raw live pool so unknown
tokens still fall through.
- src/lib/providers: rankProviders (drives /products hub wins count
and per-chain leadership chips) now runs on the reliable pool.
Adds a consistency test locking that rankedCandidates[0].slug always
matches leader().slug on the same bench. Total: 6 tests in
citation.test.ts, 95/95 pass across src/.
Uses (r.successRate ?? 100) for the numeric guard to future-proof
against ProviderResult shapes that skip the field. Load path still
defaults to 100 (materialize/load.ts:748) so this is inert today, but
the extra ?? removes the fragility.
/api/compare/[a]/[b] is intentionally left unfiltered: the user
picks the two providers explicitly, so the head-to-head is a
requested comparison rather than a leader claim. Applying the floor
there would drop rows the user explicitly asked to see.
* fix(hub-card): honor citation leader in the hub grid headline
Follow-up review flagged that benchmark-card.tsx re-sorted b.results
locally and picked results[0], so a bench where citation demotes the
raw-p50 winner (e.g. Owlracle on gas-estimation) would show the
demoted provider on the hub grid while the bench page names the
correct leader. Two documents contradicting each other in one click.
Adds leaderSlug: string | null to BenchmarkCardData and populates it
at the projection boundary via leader(b), which already applies the
reliability + insufficient-sample filter. The card picks the leader
by matching that slug in the projected results, and falls back to the
raw best when the projection returned no leader (draft, insufficient,
or all providers filtered out) so a card still renders a headline
value in every state.
Kept the local sort so the fallback path and any downstream chip
using sorted[0].name keeps working.
Test suite unchanged: 95/95 pass across src/. No test needed since
this is a projection-boundary wire-up.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix(registry): remove duplicate topdog entry (#1177)
Commit 349b786 (hl frontends: add slash, topdog, markets-mobile logos +
registry entries) added a 'topdog' entry at line 313 without noticing a
pre-existing 'topdog' entry at line 1061, breaking staging build with a
duplicate object literal type error.
Keeps the newer entry (Telegram bot at t.me/topdog_trade_bot, HL
frontend context) since it was the one intentionally added for the
Hyperliquid frontends leaderboard chip work. Drops the older Discord
signal bot entry (topdog.gg): the bench spec at
benchmarks/hyperliquid-frontends.yml has slug: topdog with name TopDog
and matches the newer, HL-native product.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* vague2 QA: unit sec for true-seconds benches (fixes 0.0s heros), soften 080 findings, drop dead 085 builder rows, 083 expected_n 40 (#1178)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster (#1179)
Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).
Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
number, returns parentHash as the observed hash. Same latency
measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
polkadotStaleBlockGap = 40. Consensus observation is skipped
because chain_getHeader returns parentHash (N-1), not the current
block hash (deriving current hash needs Blake2 over the SCALE-
encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
Polkadot state accesses via state_getStorage keyed by Blake2 storage
keys, no chain-agnostic depth analog for eth_getBalance-by-depth.
Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
Substrate keyless)
Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.
Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
rpc-capabilities benchmark, chain cluster page and hub card
render it identically.
- Methodology documents the Substrate specifics: probe method
choice, staleness scaling, why no archive-depth or hash-quorum
columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
/ {{p50:slug}} template surface, consistent with other -rpc
benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
Foundation grantee status so the leaderboard reads correctly
for someone who knows the Polkadot infra landscape.
Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
citation reliability floor and consistency tests added in #1175.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* product pages: 9 block-builder registry entries + verified logos, oracle logos (pyth/redstone) (#1181)
* products: registry entries + logos for bench 085 block builders
* oracle logos: redstone official logo + pyth/redstone manifest + registry entries
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* 085: add bobthebuilder provider row (real share, was metrics-only) (#1182)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184)
* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster
Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).
Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
number, returns parentHash as the observed hash. Same latency
measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
polkadotStaleBlockGap = 40. Consensus observation is skipped
because chain_getHeader returns parentHash (N-1), not the current
block hash (deriving current hash needs Blake2 over the SCALE-
encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
Polkadot state accesses via state_getStorage keyed by Blake2 storage
keys, no chain-agnostic depth analog for eth_getBalance-by-depth.
Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
Substrate keyless)
Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.
Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
rpc-capabilities benchmark, chain cluster page and hub card
render it identically.
- Methodology documents the Substrate specifics: probe method
choice, staleness scaling, why no archive-depth or hash-quorum
columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
/ {{p50:slug}} template surface, consistent with other -rpc
benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
Foundation grantee status so the leaderboard reads correctly
for someone who knows the Polkadot infra landscape.
Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
citation reliability floor and consistency tests added in #1175.
* polkadot page + parity/onfinality/dwellir products
Wires the /chains/polkadot hub page, provider profiles, and logo
assets that surface once bench 086 polkadot-rpc starts feeding Prom.
- chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT).
Auto-populates /chains/polkadot once benches referencing chain="polkadot"
land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables
when the chain-kpis harness starts sourcing Polkadot alongside the
other L1s.
- provider-registry.ts: three new entries for the Polkadot cohort.
* parity: brand entry for Parity Technologies (Polkadot / Substrate
implementation shop, operator of rpc.polkadot.io).
* polkadot-official: endpoint-level entry describing the reference
rpc.polkadot.io keyless endpoint; providers.ts aliases the slug
to parity so the /products page collapses under the operator's
brand.
* dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless
Substrate infra) — excluded from the 086 launch cohort due to a
503 during the audit sweep but kept in the registry for the
revisit and for other places their brand might appear.
- providers.ts: add polkadot-official → parity alias and canonical
brand casings for polkadot / parity / onfinality / dwellir so the
/products hub, badges, and OG images render the correct name
without the title-case fallback butchering them.
- logo-manifest.ts: polkadot.png (chain hero), parity.png (product page
logo), plus polkadot-official → parity slug alias so the endpoint
chip on the bench page picks up Parity's mark automatically.
95/95 pass on bun test src/. Local build clean.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* store: last-known-good snapshot fallback (no more awaiting after first publish) (#1188)
publishSnapshot writes an lkg copy without TTL on every success;
readMaterialized falls back to it when the pointer/blob pair misses
(worker outage past blob TTL, DEL race, transient). dataAsOf keeps the
freshness badge honest about age. LKG write is best-effort and never
fails the publish.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* chain-kpis: add polkadot to registry (DOT native, stables via Asset Hub) (#1191)
Ships live KPIs on /chains/polkadot. DOT price and mcap flow through
Mobula. Stables card renders via DefiLlama Asset Hub aggregate.
TVL and DEX gauges stay unpublished (relay chain has zero DeFi:
parachain TVL lives under Acala, Moonbeam, Hydration).
Also guards defillama TVL publish to skip zero values so chains that
DefiLlama tracks by name but with an all zero series (Polkadot today)
hide the TVL card instead of rendering $0.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* live-feed status: Feed down pill + banner on aggregator-head-lag (#1193)
Short-window changes() probe per provider surfaces silent live feeds
that the 24h quantile aggregate hides (Codex WS blackout 08:23-09:04
UTC today moved the p50 by nothing but the whole feed was mute for
40min). Bench-level probe_ok gate suppresses badges when our harness
side is broken so a Railway crash cannot fake-flag every provider.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* series gaps: null the chart line when a feed goes silent (unless changes==0) (#1195)
Companion to feed-down banner: makes the 40min Codex blackout from
this morning (08:23-09:04 UTC) visible on the 24h chart instead of a
flat line covering the outage. Per-series match preserves the
aggregate line as long as one chain still receives events.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* add bench 087 ws-head-latency-base + 088 ws-head-latency-solana (#1196)
Per-chain siblings of bench 081 for the WebSocket head-lag cluster.
Same harness (harnesses/ws-head-latency), same race scoring, one
spec per chain to avoid mixing block cadences (Ethereum 12s vs Base
2s vs Solana 400ms) in a single leaderboard.
Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does
not expose a keyless Base WebSocket path.
Solana cohort: PublicNode + Solana Foundation. slotSubscribe
replaces newHeads on the harness side; metric names are shared so
the queries are structurally identical to the EVM specs.
Data already flows via the Paris VPS deployment (chain label carries
base and solana). No harness change needed for this ship. Multi-region
is a follow-up on the parent bench.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: multi-region (harness region label + spec region tabs) (#1198)
* ws-head-latency: region label via ConstLabels + Railway config
Adds a static region ConstLabel to every ws-head-latency metric,
bound at process startup from OCB_REGION (preferred) or
RAILWAY_REPLICA_REGION (Railway auto-set), with eu-west fallback
matching the historical single-vantage Paris VPS deployment.
No observation site needs to change: the label is baked into every
counter, gauge and histogram at registration, so all existing
WithLabelValues(provider, chain) calls stay identical.
Adds railway.toml so Railway can build the harness from Dockerfile
with a /health check, matching the rpc-capabilities service layout.
Follow-up: create the Railway ws-head-latency service with 3
replicas (us-east, eu-west, sgp) and OCB_REGION set on each, then
decommission or leave the Paris VPS container as a 4th vantage.
* ws-head-latency specs: add region dimension tabs (081 + 087 + 088)
Adds `dimensions.region` block + `rank_matrix_query` on provider,
region + per-provider `regions:` sub-arrays with region-scoped
queries on the 3 ws-head-latency specs, following the polkadot-rpc
pattern.
Depends on the harness region label change (previous commit): every
metric now carries a region ConstLabel so the region-scoped
histogram queries return values as soon as multi-region replicas
publish. Until Railway 3-replica deploy lands, only region=eu-west
has data and the other tabs render empty; the All regions tab keeps
working because the aggregate query is unscoped by region.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: align region env var with rpc-capabilities (REGION not OCB_REGION) (#1200) (#1201)
Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* chart: red band overlay for null-bucket downtime windows (#1203)
Companion to #1195 (series null gaps). The gaps themselves render as
line breaks but a break can read as 'the chart missed a sample'. A
soft red column behind the break spans the exact outage window and
makes the reader understand it was a real feed-side silence. Per
line + per contiguous gap range, extended by one step so 1-bucket
outages stay visible instead of collapsing to a zero-width sliver.
Hidden for excluded (legend-toggled) providers.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* rename ws-head-latency -> ws-head-latency-ethereum for sibling slug parity (#1204)
081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve).
Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* network-coverage: drop coinstats (free tier credits exhausted) (#1206)
CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned.
Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API).
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* Revert "network-coverage: drop coinstats (free tier credits exhausted) (#1206)" (#1208)
This reverts commit d295f7168da981078164119e508ca9546ed69fa2.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime bands: provider-colored tint + name/silent label + edge markers (#1210)
Feedback on #1203: the neutral red band read as 'something happened
here' without telling the reader WHO. Tint each band in the
provider's own color, add dashed edges at the outage boundaries, and
label 'PROVIDER · SILENT' above the band when it fits (with a caret
that survives on narrow single-bucket bands). Works uniformly for
Mobula/Codex/Gecko or any future provider with a live_activity
query.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime bands: pill labels + vertical stacking on overlap (#1211)
Screenshot review: the small text-only label above the band was still
too subtle to read as 'this provider is down', and 3 simultaneous
provider outages produced overlapping unreadable labels stacked on
the same y. Replace with high-contrast rounded pills (white text on
provider color) that stack vertically when their X-ranges overlap.
Renders as '● NAME DOWN' in bold small caps so a reader gets the
event and the provider identity at a glance.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tsp-arb-latency: lower trigger 50 -> 25 bps + credit sub-poll closures (#1212)
Bench 080 published only 7 samples across 3 of 11 assets in 24h at
50 bps trigger. Two contributing causes fixed together:
1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on
Nasdaq (earnings, macro prints), not a steady-state event. 25 bps
is above normal intra-minute noise on liquid stocks and captures
real arb-worthy moves without collapsing the semantic.
2) Sub-poll convergence credited. When the reference moves past the
trigger AND the pool is already within band on the same tick, the
arb closed inside one poll interval. Previously dropped silently;
now observed at pollInterval (60s) so the fast-arb tail on liquid
pools stops disappearing from the histogram.
Convergence threshold stays at 20 bps: the "how close is the pool to
fair" question is unchanged, only trigger sensitivity moves. Spec
text (subtitle, methodology, FAQ) updated to 25 bps throughout.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime pill: drop the leading dot indicator, center the label (#1213)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime pill: 'DATA MISSING' wording + skip leading gaps (#1215)
Two fixes based on 30D view review:
- Leading gaps (Prom retention shorter than the visible window,
harness started mid-range, provider added recently) rendered as
a chart-wide band claiming every provider was down for the
missing period. Skip runs that started before the first observed
sample.
- Rename 'DOWN' to 'DATA MISSING'. A silent series can be the
provider going down, our harness losing its WebSocket, or a Prom
scrape failure — the pill stays neutral instead of blaming the
provider.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* drop bench 084 indexer-latency (harness pruned, spec deleted) (#1218)
HyperSync and The Graph need paid keys for the sustained cadence the
bench expects; Mobula alone left the leaderboard single-provider and
the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to
drop the whole cluster than to buy 2 SaaS subscriptions to prop up
a bench nobody has cited yet.
Removed:
* benchmarks/indexer-latency.yml
* harnesses/indexer-latency/ (whole directory)
Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns
Gone rather than 404. Cache keys bumped so cached v30/v33 entries
that still enumerate the slug age out on next revalidate.
VPS follow-up (separate ops): stop ocb-indexer-latency container,
remove from docker-compose.yml + prometheus.yml scrape config.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* video/render: strict Zod body schema before upstream forward (#1216)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* mcp: allowlist perp_funding_*, perp_venue_*, perp_execution_* prefixes (#1220)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: drop 'unless changes==0' from series queries (#1221)
Bug: the guard was designed for modern 15s-cadence data, where
changes([15m]) works cleanly. On backfilled historical data compacted
to ~1 sample/hour, changes([15m]) returns 0 even though the gauge is
healthy — the 30D view lost the first 8 days of data to false-drops
(all three providers marked DATA MISSING for a whole week).
Real-time silence is still surfaced by the live_activity scalar
(pill + banner + downtime band), which runs against fresh scrape
cadence and stays accurate. The chart series now shows every point
Prom has, flat during old outages instead of gapped.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* seo: 200 placeholders + sitemap threshold buffer (#1222)
- badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data
- hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder
- compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate
Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: restore outage detection with backfill-safe guard (#1224)
Follow-up to #1221. Dropping the guard entirely restored the 30D
range but silently hid today's Codex morning blackout on the 24H
view (frozen gauge value = flat line, indistinguishable from a
healthy quiet period).
Bring the guard back, gated on count_over_time > 5 samples in the
window. Modern data at 15s cadence carries ~60 samples per 15 min
so the gate fires the moment a WS goes silent. Backfilled data
compacted to ~1 sample/hour has count <= 2 in a 15m window so the
gate stays open and every point Prom kept is served — no more fake
wall-to-wall gap at the start of the 30D range.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: sub-ms histogram floor + keyed slots for base/solana (#1225)
Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a
[0,5ms] first bucket interpolates every winner's p50 to ~2.5ms
(chain-independent). Replace with a 0.5ms floor climbing to 10s so
sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s).
Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode)
and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can
extend beyond keyless once contributor keys are wired. Keyless tier
is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC,
BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack-
public — none accepted a keyless WSS handshake as of 2026-07).
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ship: ungate bench 085 evm-block-builders on prod (#1227)
Titan leads with sample=6973 in 24h, data healthy since the harness
ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod
next revalidate. Cache keys bumped to bench-unfiltered-v32 +
all-benchmarks-v35 so cached entries do not keep dropping the slug
via the old prod gate.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* loader: propagate 24h series null-runs to 7d/30d (#1229)
Fine-grained outages (24h step = 20 min buckets) rarely align with
coarser evaluation timestamps (7d step = 2h, 30d step = 12h) so the
Prom query returns a healthy average and the reader sees the pill on
24H but nothing on 7D/30D. Post-process: for every contiguous null
run detected on s24, translate the time-ago range to bucket indices
on s7 and s30 and null every coarse bucket the run straddles.
Contiguity preserved so a single short outage doesn't split into two
adjacent pills after propagation. Same treatment applied to per-region
series.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* propagateNullsToCoarser: use absolute time-ago instead of window fraction (#1230)
Bug: the previous math scaled by the fine window fraction, which
projected a 7h-ago outage down to 40h-ago on the 7d grid (the pill
landed at -1.7d instead of -6h). Both grids share 'now' as their
right anchor and only the step sizes differ — take the fine idx's
absolute time-ago and divide by the coarse step to find the
covering bucket.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL AMZN MSFT SPY) (#1231)
Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* perp-fees harness: fix Paradex depth-skip, gains SOL slot, dYdX walk order, GMX Subsquid error surfacing (#1233)
- Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values.
- gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL.
- dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers.
- gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path.
Verified: go build + go vet clean.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime bands: opt-in via live_activity, skip on other benches (#1235)
Screenshot review: rpc-reliability rendered a 'LAVANODIES DATA MISSING'
pill even though its spec never declared live_activity — natural
nulls in its series24h (sparse scrapes, backfill edges) triggered
the DowntimeBands render. Gate the render on
benchmark.results.some(r => r.liveStatus != null), which is only set
when the spec declared live_activity. Same gate on
propagateNullsToCoarser so other benches' 7d/30d series stay
untouched.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* batch 2: MEV chips + rpc copy + Aster/edgeX + Cardano/Sui/Cosmos/Avax (#1236)
* site: provider_notes chip on ranking rows + rpc-capabilities copy fixes
- validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat
- New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render)
- Chip renders only when the note is set, no visual change to any other bench
- rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ
- rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST)
* perp-fees harness: add Aster + edgeX venues
- Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API)
- edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0)
- Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers
- Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume)
* validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche
Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains.
- Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io.
- Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs.
- Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission).
- Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100).
Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* rpc-capabilities: add 4 keyless providers (ThirdWeb Blast Gateway.fm bloXroute) (#1238)
Broadens the keyless cohort for benches 010 (rpc-capabilities), 083
(rpc-reliability) and the per-chain RPC benches. All 4 providers
verified live returning valid eth_getBlockByNumber on their
respective chains before inclusion.
New (provider, chain) cells:
ThirdWeb 8 chains (ETH POL ARB OP BASE BSC AVAX LINEA)
Blast API 4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier
bloXroute 3 chains (ETH BASE BSC)
Gateway.fm 1 chain (ETH)
Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix
groups by provider+chain) and the per-chain leaderboards after the
Railway rebuild picks up main.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: add Onfinality Ethereum to keyless cohort (#1241)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* drop oracle-freshness (082) spec + sync REMOVED with main state (#1245)
Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal.
Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader).
Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state.
Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* SEO/E-E-A-T pack: /team page, Person schema, 23 positioning, 8 expert_take (#1249)
Adds a broad SEO and GEO upgrade landing three surfaces at once:
- /team page with Florent Tapponnier as Maintainer, sameAs GitHub +
LinkedIn, Person JSON-LD wired into Organization.founder and into
every bench StatisticalReport.contributor + TechArticle.author +
reviewedBy so YMYL crypto queries see a named human accountable.
- 23 positioning fields on alternatives specs (200 words each,
factual, sourced from official docs, fact-checked via WebFetch).
Cuts the shared-template surface from ~65 percent to ~30 percent
across pages that reference the same benchmark.
- 8 expert_take fields on answers specs (150-200 words each,
structural commentary that stays valid regardless of who currently
leads the leaderboard) with QAPage schema so the acceptedAnswer
(Org, auto) and suggestedAnswer (Person, expert_take) both reach
Google + AI Overviews.
- Byline on answers pages when expert_take is present, linking to
/team, hidden when dataPending so editorial context never renders
next to an empty leaderboard.
- noindex on awaiting benches so Prom outages do not surface thin
placeholder pages to crawlers.
- Optional Zod fields positioning + expert_take + expert_take_reviewed
with noAiDashes refine so future drafts stay in policy.
- Sitemap prebuild script emits real git-commit timestamps for the
8 editorial pages so lastmod stops resetting to build time on every
unrelated Vercel deploy.
- scripts/editorial-check.sh lints the two new fields for em/en
dashes, marketing adjectives, pronouns, year references.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* alternatives: render positioning + clean paragraph line-wrap (#1250)
Two fixes on top of #1249:
1. Add a Positioning block on /alternatives/[slug]/page.tsx that
renders the newly-added field. Phase 1 added the Zod schema
field but never wired the UI, so the content sat in YAMLs
invisible on staging.
2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
single line per paragraph. Previous version hard-wrapped at
180 chars, which YAML literal (|) preserves as newlines,
splitting words mid-token in the rendered HTML.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hotfix: strip glued expert_take_reviewed from status line (#1251)
* alternatives: render positioning + clean paragraph line-wrap
Two fixes on top of #1249:
1. Add a Positioning block on /alternatives/[slug]/page.tsx that
renders the newly-added field. Phase 1 added the Zod schema
field but never wired the UI, so the content sat in YAMLs
invisible on staging.
2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
single line per paragraph. Previous version hard-wrapped at
180 chars, which YAML literal (|) preserves as newlines,
splitting words mid-token in the rendered HTML.
* fix: strip glued expert_take_reviewed from status line
The previous rewrite regex dropped the newline before existing
expert_take_reviewed values without adding a proper break, so
'status: live' and 'expert_take_reviewed: ...' ended up on the
same line, failing YAML parse and killing the build export step.
Also collapsed duplicate expert_take_reviewed lines that landed
during the double-run of the rewrite script.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hotfix: strip glued status line in 8 answer YAMLs (#1252)
Fix that landed in a prior branch was lost during a squash-merge
sequence. Reapplying directly off dev to guarantee it lands.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* answers: drop visible byline on expert_take blocks (#1253)
The visible "By Florent Tapponnier, reviewed <date>" byline
implied authorship of every expert_take paragraph and dated the
review with a fabricated timestamp, both editorially fragile.
Keep the E-E-A-T signal at the schema layer only: Person JSON-LD
on Organization.founder, StatisticalReport.contributor, TechArticle
author + reviewedBy, and QAPage suggestedAnswer.author. Search
engines see the human attribution where it counts; readers see the
editorial commentary without a personal claim on every crypto
opinion the site publishes. Removes the unused Byline component too.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* drop bench 080 tokenized-stock-arb-latency (Rob Chain pools too illiquid) (#1254)
5 of 11 tickers had frozen pool prices (no swap activity). Remaining 6
had p99 pinned at the 32min histogram cap because arbs rarely close
within the settle window. Robinhood Chain launched Mar 2026, tokenized
equity market still too young for a meaningful arb-latency bench.
Removed:
* benchmarks/tokenized-stock-arb-latency.yml
* harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
* arb_tracker calls + closeStaleOnStateChange in main.go
Kept: bench 077 tokenized-stock-peg + 079 weekend-drift (static peg
measurements) stay live on prod as the honest signal for this cluster.
Slug in REMOVED_BENCH_SLUGS for 410 Gone. Cache keys bumped.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* jsonld: add 'name' to TechArticle nodes for Google Rich Results validation (#1257)
GSC flagged 'Missing field name' on aggregator-head-lag JSON-LD (and by extension every bench slug page + every per-chain page + the methodology hub — all TechArticle nodes shipped only 'headline'). Schema.org Article inherits 'name' from CreativeWork; Google's Rich Results validator treats it as required even when 'headline' is present. Add 'name' alongside 'headline' with the same value on all three affected builders.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-card: propagate region/kind/venue filters + compose dynamic title (#1260)
Feedback from Maxime: exported images do not adapt to the active
filters on the bench page. A share-card exported from
/benchmarks/rpc-capabilities?chain=ethereum®ion=sgp used to render
just 'Fastest free public RPC ...' with cross-chain data; now the
loader picks up the region filter and the title reads 'Fastest free
public RPC ... Ethereum · Singapore' with the correctly scoped data.
Change:
* share-card route reads region, kind, venue URL params (symmetric
with the existing chain param). Each maps to a spec dimension and
forwards to the materialize loader so the data is filtered too,
not just the title.
* A composed contextParts string is built from the resolved dimension
labels and appended to the aggregate title. Empty when no filter
is active, no trailing separator on the default view.
* The composed title is overlaid onto the benchmark passed to each
render (spread with {...b, title: displayTitle}) so all 5 templates
(ranking, leaderboard, snapshot, headline, compare) pick it up
without any render-signature changes.
* share-section + share-section-modal read live URL params for
region/kind/venue in addition to chain when composing the card URL,
so a modal opened from a scoped bench page passes the same scope
to the exported PNG.
Follow-up (not in this PR): view / range / topN selectors, per-bench
UI in the modal to expose the dimension pickers explicitly. This PR
is the plumbing; the modal UX for exposing new pickers is next.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)
This reverts commit b0ce3ef564fe4ca2aca905245c0d4dd1c6373054.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* Revert "Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)" (#1263)
This reverts commit d7f0fcbbbb6ad0db967c285fd23f99ca76b066f5.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: peer-activity clause (on/chain,region) fixes pool-silent false positives (#1265)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-card: filter negative p50 in sortByP50 (fixes HTTP 500 on RPC benches) (#1266)
Commit c03c599 relaxed the sortByP50 filter from `p50 > 0` to `p50 !== 0` to support the negative-p50 'warming up' sentinel introduced for funding benches on the /products page. The relaxation leaked into the share-card render path, where negatives crash Satori: negative p50 values fed to Math.max produce a negative maxP50, which inverts every bar height ratio and blows up the JSX with an opaque 'Spread syntax' error mid-render.
Symptoms observed: /benchmarks/{rpc-capabilities,polkadot-rpc,ws-head-latency-ethereum,evm-block-builders}/share-card?template=ranking all returning HTTP 500. Working benches (network-coverage, perp-fees, oracle-deviation) had no negative sentinel values in their data.
Fix restores `p50 > 0` in the share-card filter. The negative sentinel handling stays on the /products page where it was designed to render (as 'warming up'). Share-card treats negatives as noise and drops them, same as zeros.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-modal: explicit dimension pickers (chain / region / kind / venue) (#1267)
Feedback from Maxime: user needs to be able to pick chain/region/kind/venue from within the share modal, not just inherit whatever the URL had when opening. Also the composed card title must reflect the pick in real-time.
Adds a DimensionRow chip component rendered above the template tabs. One row per dimension the bench declares (chainDims / regionDims / kindDims / venueDims). Each row: 'Label' + 'All' chip + one chip per option. Clicking a chip toggles state; clicking the active pick clears back to All.
State is seeded from the live URL on mount so the modal opened from a scoped bench page starts on the same scope (unchanged behaviour). User can override in-modal by clicking a different chip; state updates re-render the card preview with the new filter forwarded to the loader.
The cardSrc URL builder now reads from picker state instead of the live URL - previous implementation re-read the URL every render which meant the modal's own state changes couldn't propagate. Same URL param names on the share-card route: no server-side change needed.
Bench without dimensions: block hidden entirely, modal looks unchanged.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: ws_connected guard filters out harness-reconnect false positives (#1269)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hotfix(share-card): revert region/kind/venue passthrough + title overlay (#1270)
The region/kind/venue param plumbing (added via #1260 → reverted via
#1261 → re-added via #1263) and the display-title overlay via
{...b, title: displayTitle} still cause HTTP 500 on rpc-capabilities
share-card and other benches with dimensions. My sortByP50 fix
(#1266) turned out not to be the actual root cause; the runtime error
persists after that fix + the modal picker PR (#1267).
Reverts the route.tsx changes only:
- Restore original getBenchmark({chain}) call (no region/kind/venue
forwarding)
- Restore original switch that passes plain benchmark/filteredSafe
to renders (no {...b, title: displayTitle} spread)
- chainLabel still passed through (unchanged from pre-Maxime-feedback)
Keeps the modal DimensionRow pickers (#1267) intact for a future
retry once the true root cause is found. They currently look for
matching URL params but the server-side won't act on them until the
route.tsx re-adds forwarding safely.
Bug still open on rpc-capabilities/polkadot-rpc/ws-head-latency-*/
evm-block-builders share-cards, but now user-visible functionality
(chain filter, existing templates on non-broken benches) is fully
restored to pre-Maxime-feedback behaviour.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: strip ws_connected clause from geckoterminal (REST-only, no ws metric) (#1272)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-card: fix HTTP 500 on RPC benches (Satori AVIF/WebP + CSS var crashes) (#1273)
Two independent bugs causing pre-existing HTTP 500 on rpc-capabilities,
polkadot-rpc, ws-head-latency-*, evm-block-builders share-card
endpoints. Reproduced locally with bun dev + shimmed try/catch to
unmask the mid-stream Satori errors.
Bug 1: Satori (the JSX-to-SVG renderer next/og uses) only decodes
PNG / JPEG / SVG. Feed it AVIF or WebP bytes and it throws
'TypeError: u2 is not iterable' mid-render, surfacing as opaque
HTTP 500. publicnode.avif, drpc.webp, lava.webp all trigger this on
the RPC benches. Fix: skip AVIF/WebP extensions in
getProviderLogoDataUrl, fall back to the initials chip.
Bug 2: chipBackground / chipTextColor from src/lib/brand.ts return
CSS var references (e.g. 'var(--color-ink-soft)') when the provider
has no registered brand color. Satori evaluates var(...) to the
CSS-wide 'initial' keyword and rejects 'background: initial',
crashing the whole PNG. evm-block-builders is a full-cohort case
(Titan, BuilderNet, Quasar, Eureka, Builder+, Vanilla — none have
brand colors o…
Flotapponnier
added a commit
that referenced
this pull request
Jul 25, 2026
* solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries
* nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* nft harness: delist rarible, free tier is 100 requests per month (#1137)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees
* bridge-monitor: on-chain balance fallback + degraded flag
* bridge-monitor: self-healing metrics + slack notifiers
* bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge
* bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight
* bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer
* bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default
* bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset
* bridge-monitor: keyless dry-run support for reaper + RunDryRun
* bridge-monitor: comment style cleanup in pre-flight docs
* bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive
DailySpentUSD only ever incremented and was zeroed by every restart, so a
crash-loop minted a fresh budget each time. The tracker resets exactly at
UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default
./spend-state.json, not /tmp) and is mutex-guarded. Also declares the
package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD
(default 0.50) helper for gas bled by failed broadcasts.
* bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock
Fund-loss fixes from hostile review:
- a corrective transfer whose deposit broadcast but whose bridge status
never resolved is now TERMINAL for the scheduler slot (funds are likely
still in flight, a retry double-sends); only pre-broadcast failures may
consume another attempt. executeOnBridge keeps TxHash on error paths so
callers can tell the two apart.
- ONE corrective-transfer budget (max 2) per scheduler slot, shared across
all downgrade-ladder rungs (was per rung: up to 6 transfers per slot).
- package-level execution mutex: scheduler slots own the wallets end to
end, the reaper TryLocks and skips its tick instead of queueing.
- broadcasts that failed or timed out after getting a TxHash book a flat
FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding
unaccounted gas; all spend goes through the persisted UTC-day tracker.
- gas top-up now honors MaxDailySpendUSD before broadcasting.
* bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode
The reaper (and any other consumer) used to accept simulated balances even
in production with broadcast-capable keys, so phantom numbers could
green-light real transfers. SimulateBalances now takes the execution mode
and returns nil outside dry-run; startup logs a warning when the flag is
set in a broadcast-capable mode.
* tx executor: preserve hash on ambiguous send errors, split dry-run spend state
* executor: add missing chain label, first real single-test panicked post-fill (#1141)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated)
* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tsp: parse the flat spark shape (close/timestamp arrays), log empty parses
* tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes
* rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages
* rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family
* rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions
* registry: googl links to google.com, abc.xyz 403s aggressively
* cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves
* fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152)
* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident)
* bridge-monitor: sub-50ms buckets + nanosecond precision on Observe
Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000]
to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents
on solver-cached corridors (HyperCore in particular) returns via a
1Click coordinator fast path in <50ms, and the previous scheme lumped
every one of those observations into (0, 50] so histogram_quantile
linearly interpolated to ~25ms regardless of true value. The p50 shown
on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a
bucket floor, not a measurement. Sub-50 buckets let the real bimodality
surface.
Also swap Observe(float64(quoteLatency.Milliseconds())) for
Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6
per-bridge observers (across, debridge, lifi, mobula, nearintents,
relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0,
same bug class as the RPC harness fix in PR #1128. Not a hot path here
(network RTT dominates), but keeps precision consistent with the new
low buckets and cheap to fix while we are already touching each file.
Bench YAML methodology + FAQ updated to reflect the new bucket set.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154)
* fix: 3 machine-readable surface bugs surfaced by GEO audit
1. /api/citable/{date} unit scaling.
Snapshot route emitted `value: 627` for `aggregator-head-lag` while the
live /api/citable emitted `value: 0.63` on the same measurement. The
live route already wraps with valueInDeclaredUnit(raw, b.unit) but the
snapshot route was left with the raw internal-ms value. Wrap the same
way; sub-1 s benches now render in the declared `s` unit on both routes.
2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=.
getBenchmark accepted a filters object but the route never parsed the
URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the
cross-chain aggregate. Parse the four dimension params, pass through
to the loader; unknown values fall back to unfiltered inside the
loader. Also widen getBenchmark options type to include kind and
venue — the underlying loadBenchmark already supports both.
3. Draft benches leaked a fake freshness signal.
Draft placeholder set lastRunAt to new Date for type safety
(Benchmark.lastRunAt is non-null), and both citable routes passed it
straight through as asOf. LLM crawlers treating asOf as ground truth
would think a draft bench was measured every minute. Null asOf in the
JSON when status is draft; the internal type stays intact.
* fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified
The GEO audit review found the initial fix in commit f6f84b8 only covered
the two /api/citable JSON surfaces, but the same "draft benches spoof
freshness" class of bug lives on three more machine-readable channels
that LLM crawlers also key on:
- /api/stat/{slug} asOf field.
- MCP tool responses (list_benchmarks, get_benchmark, resource template,
and the plain-text "Last sample" line).
- JSON-LD dateModified on the bench page, per-chain sub-page, answer
page and alternatives page — Google, Bing, Perplexity all consume
dateModified as a freshness ranking signal.
Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every
call site. Draft benches now uniformly omit or null the freshness
timestamp; live benches unchanged. On the visible /answers page,
the "Data as of..." line and its <time dateTime> anchor also hide for
drafts rather than displaying the wall-clock placeholder.
Follow-up to PR #1154 (commit f6f84b8) covering the surfaces the review
agent flagged as still spoofing.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* feat: /api/compare/{a}/{b} + openapi.json coverage (Wave 1 P0/P1) (#1156)
* feat: /api/compare/{a}/{b} + openapi.json coverage
Two GEO surfaces flagged by the audit as high-value gaps.
## /api/compare/{a}/{b}
Head-to-head comparison as a single citable payload. LLMs asked "Mobula
vs Codex head lag" would otherwise need two /api/stat calls plus their
own delta reasoning; this endpoint returns one flat structure with per
benchmark p50 for each side, the winner on the metric's
higher-is-better convention, and the absolute delta.
The intersection logic mirrors src/lib/related-providers.ts and the
HTML /compare/{slug} page: shared benchmark = both providers appear in
it. Intentionally returns flat leader-p50 only (no chain/region
breakdowns, no cache-heavy fan out) so the agent tool call stays fast.
## openapi.json coverage
Added /api/citable/{date} (was missing despite being a real API
endpoint) and /api/compare/{a}/{b} to the OpenAPI 3.1 spec. Also
documented the dimension query params (chain/region/kind/venue) on
/api/stat/{slug} that shipped in the previous PR but were undocumented.
Added Compare schema; extended Stat schema with `filters` field.
/llms.txt and /llms-full.txt stay outside the OpenAPI surface — they
are text files not RPC endpoints, and OpenAPI consumers do not handle
plain-text specs well anyway.
## citableAsOf helper widened
Type signature relaxed from full Benchmark to
`Pick<Benchmark, "status" | "lastRunAt">` so the slim
ProviderAppearance.benchmark used by the new compare route can pass
through without a downcast. No behavior change on existing callers.
## Notes on items NOT shipped in this batch
- Cache-Control on /llms.txt / /api/citable / /api/llm-context: the
audit report showed `cache-control: public` and inferred no CDN
caching, but the source already sets
`public, s-maxage=300, stale-while-revalidate=900`. Vercel strips the
`s-maxage` directive from the client-visible header while honoring it
at the edge; `x-vercel-cache: HIT` with `age: 256` confirms the CDN
cache is active. Not a real bug.
- .bib / .ris file MIME endpoints: string versions already ship inside
the /api/stat JSON via cite.bibtex / cite.apa. Marginal upside for
Zotero-style one-click imports; downgraded to P2.
- /api/stat/{slug}/sparkline.svg: sparkline data already ships as a
float array in /api/stat and the OG image at /api/og/{slug} covers
the "embed a chart in an answer" case. P2.
- JSON-LD `Dataset.variableMeasured.value`: current schema emits bare
strings; upgrading to PropertyValue objects would duplicate the
measured value already carried by the StatisticalReport node's
Observation.measuredValue. P2.
* openapi: add totalWins to Compare schema
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* feat: Wave 1 P2 batch — cite MIME, sparkline SVG, Dataset PropertyValue (#1158)
* feat: Wave 1 P2 batch — .bib/.ris/apa MIME, sparkline SVG, Dataset PropertyValue
Three GEO surface additions the audit downgraded to P2. Each one is
duplicative with an existing channel (cite.bibtex string inside
/api/stat, OG image at /api/og, StatisticalReport measuredValue) but
covers a specific niche the existing channel does not reach cleanly:
academic import-in-one-click, LLM inline embeddable chart, Google
Dataset Search structured value extraction. Ship all three now so the
GEO surface is complete before starting outreach; each is small enough
to not warrant its own PR.
## /api/cite/{slug}/{format}
Per-bench citation with the correct MIME type. Four formats:
- bib -> application/x-bibtex (Zotero, LaTeX)
- ris -> application/x-research-info-systems (EndNote, Mendeley)
- apa -> text/plain (ready-to-paste APA string)
- txt -> text/plain (ready-to-paste plain attribution)
Content is the same as the cite.* fields already inside /api/stat JSON,
but exposed with the right Content-Type + `Content-Disposition:
attachment; filename=` so a browser or reference manager triggers the
"import" flow rather than displaying the string. Added RIS to CiteBundle
in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2
/ ER fields, CRLF line endings per RFC convention).
## /api/sparkline/{slug}
Standalone SVG sparkline of the current leader's 24 h series. Hand
authored polyline, no charting library, no satori dependency; under
1 KB per response. Query params: `w` (40..1200, default 240), `h`
(20..400, default 60), `theme=light|dark`. Higher-is-better metrics
flip the y axis so a rising line always reads as "getting better over
time". Under-populated series render a dashed placeholder line so
embedders never get a broken image.
Cases this reaches that /api/og and the raw JSON sparkline array do
not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to
posts, Notion pages. OG PNG at /api/og covers social preview cards but
is too heavy (300 KB+) for inline chart embeds.
## Dataset.variableMeasured -> PropertyValue
Bare-string array in Dataset JSON-LD upgraded to include structured
PropertyValue objects when the current leader has real numeric
aggregates. Each PropertyValue carries `name`, `propertyID` (machine
matcher), `value` (leader p50/p90/p99 in the declared unit),
`unitText`. sample_size stays a bare string — it labels an axis, not a
measurement. Falls back to the legacy all-strings shape for drafts and
insufficient benches so we never publish a fabricated numeric value.
New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts.
BenchDatasetInput.variableMeasured now accepts
Array<string | VariableMeasuredValue>. `keywords` extraction pulls the
`.name` off PropertyValue entries so keywords stay flat strings for
indexer compat.
## openapi.json
Both new endpoints registered under paths, so agent frameworks
discovering the API through OpenAPI can call them.
* sparkline: XML-escape bench title + provider name in SVG
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* bridge-monitor: Mobula EIP-712 signature-confirm flow (fixes stuck deposits) (#1160)
* bridge-monitor: implement Mobula EIP-712 signature-confirm flow (fix stuck deposits)
* mobula sig: inject EIP712Domain type definition, API omits it
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081)
* subsidy is a chain KPI, not a bench: kill standalone spec, add 3 cards to /chains/robinhood strip
* pm-api-latency: add Predexon (bench 6th provider), fresh key deployed on the 3 Railway probe regions
* usdy: 3-retry on Orca RPC to fix 40% success rate (#1168)
publicnode.com burst-limits the VPS on getAccountInfo, dropping
~60% of samples. Two retries with 500ms backoff bring hit rate to
>95% without switching endpoints. Also add commitment=confirmed
to reduce slot-miss null returns.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* bench vague 2: ship 081-085 (ws latency, oracle freshness, rpc reliability, indexer latency, block builders) (#1170)
* add bench 081 ws-head-latency: WS block-push lag harness + spec
* add bench 084 indexer-latency: HyperSync vs The Graph vs Alchemy time-to-queryable
* idx-latency: only classify error when every poll errored, else timeout
* bump bench-set cache keys for 084 (lockstep rule)
* add bench 082 oracle-freshness: staleness metrics in oracle-deviation harness (multi-chain chainlink, pyth publish_time fix, redstone), bump bench-set cache keys
* oracle-freshness: count unsupported mainnet feeds in freshness error counter
* add bench 085 evm-block-builders: ETH builder share via extraData + relay bidtraces + Arb/Base soft-conf lag, bump bench-set cache keys
* ebb: don't count historical relay payloads on first poll, retry arb calibration on zero support, reset WS backoff after stable session
* add bench 083 rpc-reliability: consensus lag, hash quorum, fixed-vector integrity in rpc-capabilities harness, bump bench-set cache keys
* bench 083: clamp_min rank_matrix_query so zero-incident providers keep cell ranks
* gate benches 081-085 behind staging until 48h clean data
* drop shipped RWA slugs from prod gate (live on main since #1162/#1167)
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hyperliquid frontend page: add ProviderLogo in the hero next to the name
* hl frontends: add slash, topdog, markets-mobile logos + registry entries; dark-mode chip for slash + markets-mobile
* idx-latency: swap Alchemy for Mobula in the cohort (paid-plan key, bench races $0 pipelines only) (#1176)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix(bench): sample-size PromQL + leader reliability floor (#1175)
* fix(bench): sample-size PromQL + leader reliability floor
Two bench-correctness fixes surfaced by an audit of the oracle and
gas surfaces we plan to cite in outreach.
Bench 025 (oracle-deviation):
- sample_size was 'count(count by (source) (ocb_oracle_price))'
which returns the count of distinct sources (3 or 4), not the
count of samples over the window. The citable API therefore
reported sampleSize=3-4 on every pair, at odds with the spec's
'polled every 30s' claim. Rewrites the query to
'sum(count_over_time(ocb_oracle_price[24h]))' across all 10
pair entries so the number reflects actual poll volume.
Leader ranking:
- citationCandidates() now excludes providers with a measured
success rate below 50 percent from the leader pool. Before this
fix, gas-estimation showed Owlracle as #1 with successRate=6.48
and a p50 gap of 0.001 gwei: technically accurate on the rare
successful poll but useless as a leader claim. Falls back to the
full live pool when every provider is under the floor so a
totally degraded bench still surfaces a best-of-bad-options
leader rather than vanishing. Freshness benches without a
success query default to 100 percent in the loader so the guard
is inert there.
Adds src/lib/citation.test.ts with 5 tests covering the Owlracle
scenario, higher-is-better ranking, and the degraded-pool
fallback.
* fix(bench): propagate reliability floor to rankings + template + hub
Follow-up on the previous commit. Review flagged that filtering the
leader without filtering the ranking arrays leaves self-contradictory
JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle
on the same document.
Exports citationCandidates and adds rankedCandidates helper that
returns the same eligible pool sorted by the bench's higher_is_better
direction. leader() and fieldValue() now share it.
Wires the shared helper into every surface that emits a rankings-like
list or a best-of claim:
- src/app/api/stat/[slug]: rankings array now sorted from
rankedCandidates so rankings[0].slug always equals leader.slug on
the same JSON.
- src/app/api/llm-context: the Rankings numbered list under each
bench matches the Headline sentence above it.
- src/app/api/mcp/[transport]: get_benchmark tool response and the
openchainbench://benchmark/{slug} resource both share the pool.
- src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}}
tokens in bench copy resolve against the same pool so a bench body
never crowns a provider its own headline excludes. Per-slug tokens
{{p50:some-slug}} still resolve against the raw live pool so unknown
tokens still fall through.
- src/lib/providers: rankProviders (drives /products hub wins count
and per-chain leadership chips) now runs on the reliable pool.
Adds a consistency test locking that rankedCandidates[0].slug always
matches leader().slug on the same bench. Total: 6 tests in
citation.test.ts, 95/95 pass across src/.
Uses (r.successRate ?? 100) for the numeric guard to future-proof
against ProviderResult shapes that skip the field. Load path still
defaults to 100 (materialize/load.ts:748) so this is inert today, but
the extra ?? removes the fragility.
/api/compare/[a]/[b] is intentionally left unfiltered: the user
picks the two providers explicitly, so the head-to-head is a
requested comparison rather than a leader claim. Applying the floor
there would drop rows the user explicitly asked to see.
* fix(hub-card): honor citation leader in the hub grid headline
Follow-up review flagged that benchmark-card.tsx re-sorted b.results
locally and picked results[0], so a bench where citation demotes the
raw-p50 winner (e.g. Owlracle on gas-estimation) would show the
demoted provider on the hub grid while the bench page names the
correct leader. Two documents contradicting each other in one click.
Adds leaderSlug: string | null to BenchmarkCardData and populates it
at the projection boundary via leader(b), which already applies the
reliability + insufficient-sample filter. The card picks the leader
by matching that slug in the projected results, and falls back to the
raw best when the projection returned no leader (draft, insufficient,
or all providers filtered out) so a card still renders a headline
value in every state.
Kept the local sort so the fallback path and any downstream chip
using sorted[0].name keeps working.
Test suite unchanged: 95/95 pass across src/. No test needed since
this is a projection-boundary wire-up.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix(registry): remove duplicate topdog entry (#1177)
Commit 349b786 (hl frontends: add slash, topdog, markets-mobile logos +
registry entries) added a 'topdog' entry at line 313 without noticing a
pre-existing 'topdog' entry at line 1061, breaking staging build with a
duplicate object literal type error.
Keeps the newer entry (Telegram bot at t.me/topdog_trade_bot, HL
frontend context) since it was the one intentionally added for the
Hyperliquid frontends leaderboard chip work. Drops the older Discord
signal bot entry (topdog.gg): the bench spec at
benchmarks/hyperliquid-frontends.yml has slug: topdog with name TopDog
and matches the newer, HL-native product.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* vague2 QA: unit sec for true-seconds benches (fixes 0.0s heros), soften 080 findings, drop dead 085 builder rows, 083 expected_n 40 (#1178)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster (#1179)
Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).
Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
number, returns parentHash as the observed hash. Same latency
measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
polkadotStaleBlockGap = 40. Consensus observation is skipped
because chain_getHeader returns parentHash (N-1), not the current
block hash (deriving current hash needs Blake2 over the SCALE-
encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
Polkadot state accesses via state_getStorage keyed by Blake2 storage
keys, no chain-agnostic depth analog for eth_getBalance-by-depth.
Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
Substrate keyless)
Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.
Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
rpc-capabilities benchmark, chain cluster page and hub card
render it identically.
- Methodology documents the Substrate specifics: probe method
choice, staleness scaling, why no archive-depth or hash-quorum
columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
/ {{p50:slug}} template surface, consistent with other -rpc
benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
Foundation grantee status so the leaderboard reads correctly
for someone who knows the Polkadot infra landscape.
Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
citation reliability floor and consistency tests added in #1175.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* product pages: 9 block-builder registry entries + verified logos, oracle logos (pyth/redstone) (#1181)
* products: registry entries + logos for bench 085 block builders
* oracle logos: redstone official logo + pyth/redstone manifest + registry entries
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* 085: add bobthebuilder provider row (real share, was metrics-only) (#1182)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184)
* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster
Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).
Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
number, returns parentHash as the observed hash. Same latency
measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
polkadotStaleBlockGap = 40. Consensus observation is skipped
because chain_getHeader returns parentHash (N-1), not the current
block hash (deriving current hash needs Blake2 over the SCALE-
encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
Polkadot state accesses via state_getStorage keyed by Blake2 storage
keys, no chain-agnostic depth analog for eth_getBalance-by-depth.
Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
Substrate keyless)
Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.
Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
rpc-capabilities benchmark, chain cluster page and hub card
render it identically.
- Methodology documents the Substrate specifics: probe method
choice, staleness scaling, why no archive-depth or hash-quorum
columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
/ {{p50:slug}} template surface, consistent with other -rpc
benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
Foundation grantee status so the leaderboard reads correctly
for someone who knows the Polkadot infra landscape.
Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
citation reliability floor and consistency tests added in #1175.
* polkadot page + parity/onfinality/dwellir products
Wires the /chains/polkadot hub page, provider profiles, and logo
assets that surface once bench 086 polkadot-rpc starts feeding Prom.
- chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT).
Auto-populates /chains/polkadot once benches referencing chain="polkadot"
land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables
when the chain-kpis harness starts sourcing Polkadot alongside the
other L1s.
- provider-registry.ts: three new entries for the Polkadot cohort.
* parity: brand entry for Parity Technologies (Polkadot / Substrate
implementation shop, operator of rpc.polkadot.io).
* polkadot-official: endpoint-level entry describing the reference
rpc.polkadot.io keyless endpoint; providers.ts aliases the slug
to parity so the /products page collapses under the operator's
brand.
* dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless
Substrate infra) — excluded from the 086 launch cohort due to a
503 during the audit sweep but kept in the registry for the
revisit and for other places their brand might appear.
- providers.ts: add polkadot-official → parity alias and canonical
brand casings for polkadot / parity / onfinality / dwellir so the
/products hub, badges, and OG images render the correct name
without the title-case fallback butchering them.
- logo-manifest.ts: polkadot.png (chain hero), parity.png (product page
logo), plus polkadot-official → parity slug alias so the endpoint
chip on the bench page picks up Parity's mark automatically.
95/95 pass on bun test src/. Local build clean.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* store: last-known-good snapshot fallback (no more awaiting after first publish) (#1188)
publishSnapshot writes an lkg copy without TTL on every success;
readMaterialized falls back to it when the pointer/blob pair misses
(worker outage past blob TTL, DEL race, transient). dataAsOf keeps the
freshness badge honest about age. LKG write is best-effort and never
fails the publish.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* chain-kpis: add polkadot to registry (DOT native, stables via Asset Hub) (#1191)
Ships live KPIs on /chains/polkadot. DOT price and mcap flow through
Mobula. Stables card renders via DefiLlama Asset Hub aggregate.
TVL and DEX gauges stay unpublished (relay chain has zero DeFi:
parachain TVL lives under Acala, Moonbeam, Hydration).
Also guards defillama TVL publish to skip zero values so chains that
DefiLlama tracks by name but with an all zero series (Polkadot today)
hide the TVL card instead of rendering $0.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* live-feed status: Feed down pill + banner on aggregator-head-lag (#1193)
Short-window changes() probe per provider surfaces silent live feeds
that the 24h quantile aggregate hides (Codex WS blackout 08:23-09:04
UTC today moved the p50 by nothing but the whole feed was mute for
40min). Bench-level probe_ok gate suppresses badges when our harness
side is broken so a Railway crash cannot fake-flag every provider.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* series gaps: null the chart line when a feed goes silent (unless changes==0) (#1195)
Companion to feed-down banner: makes the 40min Codex blackout from
this morning (08:23-09:04 UTC) visible on the 24h chart instead of a
flat line covering the outage. Per-series match preserves the
aggregate line as long as one chain still receives events.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* add bench 087 ws-head-latency-base + 088 ws-head-latency-solana (#1196)
Per-chain siblings of bench 081 for the WebSocket head-lag cluster.
Same harness (harnesses/ws-head-latency), same race scoring, one
spec per chain to avoid mixing block cadences (Ethereum 12s vs Base
2s vs Solana 400ms) in a single leaderboard.
Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does
not expose a keyless Base WebSocket path.
Solana cohort: PublicNode + Solana Foundation. slotSubscribe
replaces newHeads on the harness side; metric names are shared so
the queries are structurally identical to the EVM specs.
Data already flows via the Paris VPS deployment (chain label carries
base and solana). No harness change needed for this ship. Multi-region
is a follow-up on the parent bench.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: multi-region (harness region label + spec region tabs) (#1198)
* ws-head-latency: region label via ConstLabels + Railway config
Adds a static region ConstLabel to every ws-head-latency metric,
bound at process startup from OCB_REGION (preferred) or
RAILWAY_REPLICA_REGION (Railway auto-set), with eu-west fallback
matching the historical single-vantage Paris VPS deployment.
No observation site needs to change: the label is baked into every
counter, gauge and histogram at registration, so all existing
WithLabelValues(provider, chain) calls stay identical.
Adds railway.toml so Railway can build the harness from Dockerfile
with a /health check, matching the rpc-capabilities service layout.
Follow-up: create the Railway ws-head-latency service with 3
replicas (us-east, eu-west, sgp) and OCB_REGION set on each, then
decommission or leave the Paris VPS container as a 4th vantage.
* ws-head-latency specs: add region dimension tabs (081 + 087 + 088)
Adds `dimensions.region` block + `rank_matrix_query` on provider,
region + per-provider `regions:` sub-arrays with region-scoped
queries on the 3 ws-head-latency specs, following the polkadot-rpc
pattern.
Depends on the harness region label change (previous commit): every
metric now carries a region ConstLabel so the region-scoped
histogram queries return values as soon as multi-region replicas
publish. Until Railway 3-replica deploy lands, only region=eu-west
has data and the other tabs render empty; the All regions tab keeps
working because the aggregate query is unscoped by region.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: align region env var with rpc-capabilities (REGION not OCB_REGION) (#1200) (#1201)
Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* chart: red band overlay for null-bucket downtime windows (#1203)
Companion to #1195 (series null gaps). The gaps themselves render as
line breaks but a break can read as 'the chart missed a sample'. A
soft red column behind the break spans the exact outage window and
makes the reader understand it was a real feed-side silence. Per
line + per contiguous gap range, extended by one step so 1-bucket
outages stay visible instead of collapsing to a zero-width sliver.
Hidden for excluded (legend-toggled) providers.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* rename ws-head-latency -> ws-head-latency-ethereum for sibling slug parity (#1204)
081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve).
Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* network-coverage: drop coinstats (free tier credits exhausted) (#1206)
CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned.
Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API).
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* Revert "network-coverage: drop coinstats (free tier credits exhausted) (#1206)" (#1208)
This reverts commit d295f7168da981078164119e508ca9546ed69fa2.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime bands: provider-colored tint + name/silent label + edge markers (#1210)
Feedback on #1203: the neutral red band read as 'something happened
here' without telling the reader WHO. Tint each band in the
provider's own color, add dashed edges at the outage boundaries, and
label 'PROVIDER · SILENT' above the band when it fits (with a caret
that survives on narrow single-bucket bands). Works uniformly for
Mobula/Codex/Gecko or any future provider with a live_activity
query.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime bands: pill labels + vertical stacking on overlap (#1211)
Screenshot review: the small text-only label above the band was still
too subtle to read as 'this provider is down', and 3 simultaneous
provider outages produced overlapping unreadable labels stacked on
the same y. Replace with high-contrast rounded pills (white text on
provider color) that stack vertically when their X-ranges overlap.
Renders as '● NAME DOWN' in bold small caps so a reader gets the
event and the provider identity at a glance.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tsp-arb-latency: lower trigger 50 -> 25 bps + credit sub-poll closures (#1212)
Bench 080 published only 7 samples across 3 of 11 assets in 24h at
50 bps trigger. Two contributing causes fixed together:
1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on
Nasdaq (earnings, macro prints), not a steady-state event. 25 bps
is above normal intra-minute noise on liquid stocks and captures
real arb-worthy moves without collapsing the semantic.
2) Sub-poll convergence credited. When the reference moves past the
trigger AND the pool is already within band on the same tick, the
arb closed inside one poll interval. Previously dropped silently;
now observed at pollInterval (60s) so the fast-arb tail on liquid
pools stops disappearing from the histogram.
Convergence threshold stays at 20 bps: the "how close is the pool to
fair" question is unchanged, only trigger sensitivity moves. Spec
text (subtitle, methodology, FAQ) updated to 25 bps throughout.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime pill: drop the leading dot indicator, center the label (#1213)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime pill: 'DATA MISSING' wording + skip leading gaps (#1215)
Two fixes based on 30D view review:
- Leading gaps (Prom retention shorter than the visible window,
harness started mid-range, provider added recently) rendered as
a chart-wide band claiming every provider was down for the
missing period. Skip runs that started before the first observed
sample.
- Rename 'DOWN' to 'DATA MISSING'. A silent series can be the
provider going down, our harness losing its WebSocket, or a Prom
scrape failure — the pill stays neutral instead of blaming the
provider.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* drop bench 084 indexer-latency (harness pruned, spec deleted) (#1218)
HyperSync and The Graph need paid keys for the sustained cadence the
bench expects; Mobula alone left the leaderboard single-provider and
the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to
drop the whole cluster than to buy 2 SaaS subscriptions to prop up
a bench nobody has cited yet.
Removed:
* benchmarks/indexer-latency.yml
* harnesses/indexer-latency/ (whole directory)
Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns
Gone rather than 404. Cache keys bumped so cached v30/v33 entries
that still enumerate the slug age out on next revalidate.
VPS follow-up (separate ops): stop ocb-indexer-latency container,
remove from docker-compose.yml + prometheus.yml scrape config.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* video/render: strict Zod body schema before upstream forward (#1216)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* mcp: allowlist perp_funding_*, perp_venue_*, perp_execution_* prefixes (#1220)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: drop 'unless changes==0' from series queries (#1221)
Bug: the guard was designed for modern 15s-cadence data, where
changes([15m]) works cleanly. On backfilled historical data compacted
to ~1 sample/hour, changes([15m]) returns 0 even though the gauge is
healthy — the 30D view lost the first 8 days of data to false-drops
(all three providers marked DATA MISSING for a whole week).
Real-time silence is still surfaced by the live_activity scalar
(pill + banner + downtime band), which runs against fresh scrape
cadence and stays accurate. The chart series now shows every point
Prom has, flat during old outages instead of gapped.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* seo: 200 placeholders + sitemap threshold buffer (#1222)
- badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data
- hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder
- compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate
Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: restore outage detection with backfill-safe guard (#1224)
Follow-up to #1221. Dropping the guard entirely restored the 30D
range but silently hid today's Codex morning blackout on the 24H
view (frozen gauge value = flat line, indistinguishable from a
healthy quiet period).
Bring the guard back, gated on count_over_time > 5 samples in the
window. Modern data at 15s cadence carries ~60 samples per 15 min
so the gate fires the moment a WS goes silent. Backfilled data
compacted to ~1 sample/hour has count <= 2 in a 15m window so the
gate stays open and every point Prom kept is served — no more fake
wall-to-wall gap at the start of the 30D range.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: sub-ms histogram floor + keyed slots for base/solana (#1225)
Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a
[0,5ms] first bucket interpolates every winner's p50 to ~2.5ms
(chain-independent). Replace with a 0.5ms floor climbing to 10s so
sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s).
Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode)
and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can
extend beyond keyless once contributor keys are wired. Keyless tier
is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC,
BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack-
public — none accepted a keyless WSS handshake as of 2026-07).
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ship: ungate bench 085 evm-block-builders on prod (#1227)
Titan leads with sample=6973 in 24h, data healthy since the harness
ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod
next revalidate. Cache keys bumped to bench-unfiltered-v32 +
all-benchmarks-v35 so cached entries do not keep dropping the slug
via the old prod gate.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* loader: propagate 24h series null-runs to 7d/30d (#1229)
Fine-grained outages (24h step = 20 min buckets) rarely align with
coarser evaluation timestamps (7d step = 2h, 30d step = 12h) so the
Prom query returns a healthy average and the reader sees the pill on
24H but nothing on 7D/30D. Post-process: for every contiguous null
run detected on s24, translate the time-ago range to bucket indices
on s7 and s30 and null every coarse bucket the run straddles.
Contiguity preserved so a single short outage doesn't split into two
adjacent pills after propagation. Same treatment applied to per-region
series.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* propagateNullsToCoarser: use absolute time-ago instead of window fraction (#1230)
Bug: the previous math scaled by the fine window fraction, which
projected a 7h-ago outage down to 40h-ago on the 7d grid (the pill
landed at -1.7d instead of -6h). Both grids share 'now' as their
right anchor and only the step sizes differ — take the fine idx's
absolute time-ago and divide by the coarse step to find the
covering bucket.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL AMZN MSFT SPY) (#1231)
Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* perp-fees harness: fix Paradex depth-skip, gains SOL slot, dYdX walk order, GMX Subsquid error surfacing (#1233)
- Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values.
- gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL.
- dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers.
- gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path.
Verified: go build + go vet clean.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* downtime bands: opt-in via live_activity, skip on other benches (#1235)
Screenshot review: rpc-reliability rendered a 'LAVANODIES DATA MISSING'
pill even though its spec never declared live_activity — natural
nulls in its series24h (sparse scrapes, backfill edges) triggered
the DowntimeBands render. Gate the render on
benchmark.results.some(r => r.liveStatus != null), which is only set
when the spec declared live_activity. Same gate on
propagateNullsToCoarser so other benches' 7d/30d series stay
untouched.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* batch 2: MEV chips + rpc copy + Aster/edgeX + Cardano/Sui/Cosmos/Avax (#1236)
* site: provider_notes chip on ranking rows + rpc-capabilities copy fixes
- validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat
- New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render)
- Chip renders only when the note is set, no visual change to any other bench
- rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ
- rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST)
* perp-fees harness: add Aster + edgeX venues
- Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API)
- edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0)
- Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers
- Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume)
* validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche
Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains.
- Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io.
- Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs.
- Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission).
- Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100).
Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* rpc-capabilities: add 4 keyless providers (ThirdWeb Blast Gateway.fm bloXroute) (#1238)
Broadens the keyless cohort for benches 010 (rpc-capabilities), 083
(rpc-reliability) and the per-chain RPC benches. All 4 providers
verified live returning valid eth_getBlockByNumber on their
respective chains before inclusion.
New (provider, chain) cells:
ThirdWeb 8 chains (ETH POL ARB OP BASE BSC AVAX LINEA)
Blast API 4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier
bloXroute 3 chains (ETH BASE BSC)
Gateway.fm 1 chain (ETH)
Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix
groups by provider+chain) and the per-chain leaderboards after the
Railway rebuild picks up main.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* ws-head-latency: add Onfinality Ethereum to keyless cohort (#1241)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* drop oracle-freshness (082) spec + sync REMOVED with main state (#1245)
Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal.
Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader).
Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state.
Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* SEO/E-E-A-T pack: /team page, Person schema, 23 positioning, 8 expert_take (#1249)
Adds a broad SEO and GEO upgrade landing three surfaces at once:
- /team page with Florent Tapponnier as Maintainer, sameAs GitHub +
LinkedIn, Person JSON-LD wired into Organization.founder and into
every bench StatisticalReport.contributor + TechArticle.author +
reviewedBy so YMYL crypto queries see a named human accountable.
- 23 positioning fields on alternatives specs (200 words each,
factual, sourced from official docs, fact-checked via WebFetch).
Cuts the shared-template surface from ~65 percent to ~30 percent
across pages that reference the same benchmark.
- 8 expert_take fields on answers specs (150-200 words each,
structural commentary that stays valid regardless of who currently
leads the leaderboard) with QAPage schema so the acceptedAnswer
(Org, auto) and suggestedAnswer (Person, expert_take) both reach
Google + AI Overviews.
- Byline on answers pages when expert_take is present, linking to
/team, hidden when dataPending so editorial context never renders
next to an empty leaderboard.
- noindex on awaiting benches so Prom outages do not surface thin
placeholder pages to crawlers.
- Optional Zod fields positioning + expert_take + expert_take_reviewed
with noAiDashes refine so future drafts stay in policy.
- Sitemap prebuild script emits real git-commit timestamps for the
8 editorial pages so lastmod stops resetting to build time on every
unrelated Vercel deploy.
- scripts/editorial-check.sh lints the two new fields for em/en
dashes, marketing adjectives, pronouns, year references.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* alternatives: render positioning + clean paragraph line-wrap (#1250)
Two fixes on top of #1249:
1. Add a Positioning block on /alternatives/[slug]/page.tsx that
renders the newly-added field. Phase 1 added the Zod schema
field but never wired the UI, so the content sat in YAMLs
invisible on staging.
2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
single line per paragraph. Previous version hard-wrapped at
180 chars, which YAML literal (|) preserves as newlines,
splitting words mid-token in the rendered HTML.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hotfix: strip glued expert_take_reviewed from status line (#1251)
* alternatives: render positioning + clean paragraph line-wrap
Two fixes on top of #1249:
1. Add a Positioning block on /alternatives/[slug]/page.tsx that
renders the newly-added field. Phase 1 added the Zod schema
field but never wired the UI, so the content sat in YAMLs
invisible on staging.
2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
single line per paragraph. Previous version hard-wrapped at
180 chars, which YAML literal (|) preserves as newlines,
splitting words mid-token in the rendered HTML.
* fix: strip glued expert_take_reviewed from status line
The previous rewrite regex dropped the newline before existing
expert_take_reviewed values without adding a proper break, so
'status: live' and 'expert_take_reviewed: ...' ended up on the
same line, failing YAML parse and killing the build export step.
Also collapsed duplicate expert_take_reviewed lines that landed
during the double-run of the rewrite script.
---------
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hotfix: strip glued status line in 8 answer YAMLs (#1252)
Fix that landed in a prior branch was lost during a squash-merge
sequence. Reapplying directly off dev to guarantee it lands.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* answers: drop visible byline on expert_take blocks (#1253)
The visible "By Florent Tapponnier, reviewed <date>" byline
implied authorship of every expert_take paragraph and dated the
review with a fabricated timestamp, both editorially fragile.
Keep the E-E-A-T signal at the schema layer only: Person JSON-LD
on Organization.founder, StatisticalReport.contributor, TechArticle
author + reviewedBy, and QAPage suggestedAnswer.author. Search
engines see the human attribution where it counts; readers see the
editorial commentary without a personal claim on every crypto
opinion the site publishes. Removes the unused Byline component too.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* drop bench 080 tokenized-stock-arb-latency (Rob Chain pools too illiquid) (#1254)
5 of 11 tickers had frozen pool prices (no swap activity). Remaining 6
had p99 pinned at the 32min histogram cap because arbs rarely close
within the settle window. Robinhood Chain launched Mar 2026, tokenized
equity market still too young for a meaningful arb-latency bench.
Removed:
* benchmarks/tokenized-stock-arb-latency.yml
* harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
* arb_tracker calls + closeStaleOnStateChange in main.go
Kept: bench 077 tokenized-stock-peg + 079 weekend-drift (static peg
measurements) stay live on prod as the honest signal for this cluster.
Slug in REMOVED_BENCH_SLUGS for 410 Gone. Cache keys bumped.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* jsonld: add 'name' to TechArticle nodes for Google Rich Results validation (#1257)
GSC flagged 'Missing field name' on aggregator-head-lag JSON-LD (and by extension every bench slug page + every per-chain page + the methodology hub — all TechArticle nodes shipped only 'headline'). Schema.org Article inherits 'name' from CreativeWork; Google's Rich Results validator treats it as required even when 'headline' is present. Add 'name' alongside 'headline' with the same value on all three affected builders.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-card: propagate region/kind/venue filters + compose dynamic title (#1260)
Feedback from Maxime: exported images do not adapt to the active
filters on the bench page. A share-card exported from
/benchmarks/rpc-capabilities?chain=ethereum®ion=sgp used to render
just 'Fastest free public RPC ...' with cross-chain data; now the
loader picks up the region filter and the title reads 'Fastest free
public RPC ... Ethereum · Singapore' with the correctly scoped data.
Change:
* share-card route reads region, kind, venue URL params (symmetric
with the existing chain param). Each maps to a spec dimension and
forwards to the materialize loader so the data is filtered too,
not just the title.
* A composed contextParts string is built from the resolved dimension
labels and appended to the aggregate title. Empty when no filter
is active, no trailing separator on the default view.
* The composed title is overlaid onto the benchmark passed to each
render (spread with {...b, title: displayTitle}) so all 5 templates
(ranking, leaderboard, snapshot, headline, compare) pick it up
without any render-signature changes.
* share-section + share-section-modal read live URL params for
region/kind/venue in addition to chain when composing the card URL,
so a modal opened from a scoped bench page passes the same scope
to the exported PNG.
Follow-up (not in this PR): view / range / topN selectors, per-bench
UI in the modal to expose the dimension pickers explicitly. This PR
is the plumbing; the modal UX for exposing new pickers is next.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)
This reverts commit b0ce3ef564fe4ca2aca905245c0d4dd1c6373054.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* Revert "Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)" (#1263)
This reverts commit d7f0fcbbbb6ad0db967c285fd23f99ca76b066f5.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: peer-activity clause (on/chain,region) fixes pool-silent false positives (#1265)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-card: filter negative p50 in sortByP50 (fixes HTTP 500 on RPC benches) (#1266)
Commit c03c599 relaxed the sortByP50 filter from `p50 > 0` to `p50 !== 0` to support the negative-p50 'warming up' sentinel introduced for funding benches on the /products page. The relaxation leaked into the share-card render path, where negatives crash Satori: negative p50 values fed to Math.max produce a negative maxP50, which inverts every bar height ratio and blows up the JSX with an opaque 'Spread syntax' error mid-render.
Symptoms observed: /benchmarks/{rpc-capabilities,polkadot-rpc,ws-head-latency-ethereum,evm-block-builders}/share-card?template=ranking all returning HTTP 500. Working benches (network-coverage, perp-fees, oracle-deviation) had no negative sentinel values in their data.
Fix restores `p50 > 0` in the share-card filter. The negative sentinel handling stays on the /products page where it was designed to render (as 'warming up'). Share-card treats negatives as noise and drops them, same as zeros.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-modal: explicit dimension pickers (chain / region / kind / venue) (#1267)
Feedback from Maxime: user needs to be able to pick chain/region/kind/venue from within the share modal, not just inherit whatever the URL had when opening. Also the composed card title must reflect the pick in real-time.
Adds a DimensionRow chip component rendered above the template tabs. One row per dimension the bench declares (chainDims / regionDims / kindDims / venueDims). Each row: 'Label' + 'All' chip + one chip per option. Clicking a chip toggles state; clicking the active pick clears back to All.
State is seeded from the live URL on mount so the modal opened from a scoped bench page starts on the same scope (unchanged behaviour). User can override in-modal by clicking a different chip; state updates re-render the card preview with the new filter forwarded to the loader.
The cardSrc URL builder now reads from picker state instead of the live URL - previous implementation re-read the URL every render which meant the modal's own state changes couldn't propagate. Same URL param names on the share-card route: no server-side change needed.
Bench without dimensions: block hidden entirely, modal looks unchanged.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: ws_connected guard filters out harness-reconnect false positives (#1269)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* hotfix(share-card): revert region/kind/venue passthrough + title overlay (#1270)
The region/kind/venue param plumbing (added via #1260 → reverted via
#1261 → re-added via #1263) and the display-title overlay via
{...b, title: displayTitle} still cause HTTP 500 on rpc-capabilities
share-card and other benches with dimensions. My sortByP50 fix
(#1266) turned out not to be the actual root cause; the runtime error
persists after that fix + the modal picker PR (#1267).
Reverts the route.tsx changes only:
- Restore original getBenchmark({chain}) call (no region/kind/venue
forwarding)
- Restore original switch that passes plain benchmark/filteredSafe
to renders (no {...b, title: displayTitle} spread)
- chainLabel still passed through (unchanged from pre-Maxime-feedback)
Keeps the modal DimensionRow pickers (#1267) intact for a future
retry once the true root cause is found. They currently look for
matching URL params but the server-side won't act on them until the
route.tsx re-adds forwarding safely.
Bug still open on rpc-capabilities/polkadot-rpc/ws-head-latency-*/
evm-block-builders share-cards, but now user-visible functionality
(chain filter, existing templates on non-broken benches) is fully
restored to pre-Maxime-feedback behaviour.
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* aggregator-head-lag: strip ws_connected clause from geckoterminal (REST-only, no ws metric) (#1272)
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* share-card: fix HTTP 500 on RPC benches (Satori AVIF/WebP + CSS var crashes) (#1273)
Two independent bugs causing pre-existing HTTP 500 on rpc-capabilities,
polkadot-rpc, ws-head-latency-*, evm-block-builders share-card
endpoints. Reproduced locally with bun dev + shimmed try/catch to
unmask the mid-stream Satori errors.
Bug 1: Satori (the JSX-to-SVG renderer next/og uses) only decodes
PNG / JPEG / SVG. Feed it AVIF or WebP bytes and it throws
'TypeError: u2 is not iterable' mid-render, surfacing as opaque
HTTP 500. publicnode.avif, drpc.webp, lava.webp all trigger this on
the RPC benches. Fix: skip AVIF/WebP extensions in
getProviderLogoDataUrl, fall back to the initials chip.
Bug 2: chipBackground / chipTextColor from src/lib/brand.ts return
CSS var references (e.g. 'var(--color-ink-soft)') when the provider
has no registered brand color. Satori evaluates var(...) to the
CSS-wide 'initial' keyword and rejects 'background: initial',
crashing the whole PNG. evm-block-builders is a full-cohort case
(Titan, BuilderNet, Quasar, Eureka, Builder+, Vanilla — none have
brand colors or logos). Fix: resolve var(...) fallbacks to the
concrete INK_SOFT / PAPER hex f…
Flotapponnier
added a commit
that referenced
this pull request
Jul 25, 2026
…ion on bench pages) (#1429) * solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries * nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136) Co-authored-by: Florent Tapponnier <contact@mobula.io> * nft harness: delist rarible, free tier is 100 requests per month (#1137) Co-authored-by: Florent Tapponnier <contact@mobula.io> * robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees * bridge-monitor: on-chain balance fallback + degraded flag * bridge-monitor: self-healing metrics + slack notifiers * bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge * bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight * bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer * bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default * bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset * bridge-monitor: keyless dry-run support for reaper + RunDryRun * bridge-monitor: comment style cleanup in pre-flight docs * bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive DailySpentUSD only ever incremented and was zeroed by every restart, so a crash-loop minted a fresh budget each time. The tracker resets exactly at UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default ./spend-state.json, not /tmp) and is mutex-guarded. Also declares the package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD (default 0.50) helper for gas bled by failed broadcasts. * bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock Fund-loss fixes from hostile review: - a corrective transfer whose deposit broadcast but whose bridge status never resolved is now TERMINAL for the scheduler slot (funds are likely still in flight, a retry double-sends); only pre-broadcast failures may consume another attempt. executeOnBridge keeps TxHash on error paths so callers can tell the two apart. - ONE corrective-transfer budget (max 2) per scheduler slot, shared across all downgrade-ladder rungs (was per rung: up to 6 transfers per slot). - package-level execution mutex: scheduler slots own the wallets end to end, the reaper TryLocks and skips its tick instead of queueing. - broadcasts that failed or timed out after getting a TxHash book a flat FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding unaccounted gas; all spend goes through the persisted UTC-day tracker. - gas top-up now honors MaxDailySpendUSD before broadcasting. * bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode The reaper (and any other consumer) used to accept simulated balances even in production with broadcast-capable keys, so phantom numbers could green-light real transfers. SimulateBalances now takes the execution mode and returns nil outside dry-run; startup logs a warning when the flag is set in a broadcast-capable mode. * tx executor: preserve hash on ambiguous send errors, split dry-run spend state * executor: add missing chain label, first real single-test panicked post-fill (#1141) Co-authored-by: Florent Tapponnier <contact@mobula.io> * bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142) Co-authored-by: Florent Tapponnier <contact@mobula.io> * tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated) * spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144) Co-authored-by: Florent Tapponnier <contact@mobula.io> * tsp: parse the flat spark shape (close/timestamp arrays), log empty parses * tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes * rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages * rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family * rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions * registry: googl links to google.com, abc.xyz 403s aggressively * cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves * fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152) * spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) * bridge-monitor: sub-50ms buckets + nanosecond precision on Observe Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000] to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents on solver-cached corridors (HyperCore in particular) returns via a 1Click coordinator fast path in <50ms, and the previous scheme lumped every one of those observations into (0, 50] so histogram_quantile linearly interpolated to ~25ms regardless of true value. The p50 shown on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a bucket floor, not a measurement. Sub-50 buckets let the real bimodality surface. Also swap Observe(float64(quoteLatency.Milliseconds())) for Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6 per-bridge observers (across, debridge, lifi, mobula, nearintents, relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0, same bug class as the RPC harness fix in PR #1128. Not a hot path here (network RTT dominates), but keeps precision consistent with the new low buckets and cheap to fix while we are already touching each file. Bench YAML methodology + FAQ updated to reflect the new bucket set. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154) * fix: 3 machine-readable surface bugs surfaced by GEO audit 1. /api/citable/{date} unit scaling. Snapshot route emitted `value: 627` for `aggregator-head-lag` while the live /api/citable emitted `value: 0.63` on the same measurement. The live route already wraps with valueInDeclaredUnit(raw, b.unit) but the snapshot route was left with the raw internal-ms value. Wrap the same way; sub-1 s benches now render in the declared `s` unit on both routes. 2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=. getBenchmark accepted a filters object but the route never parsed the URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the cross-chain aggregate. Parse the four dimension params, pass through to the loader; unknown values fall back to unfiltered inside the loader. Also widen getBenchmark options type to include kind and venue — the underlying loadBenchmark already supports both. 3. Draft benches leaked a fake freshness signal. Draft placeholder set lastRunAt to new Date for type safety (Benchmark.lastRunAt is non-null), and both citable routes passed it straight through as asOf. LLM crawlers treating asOf as ground truth would think a draft bench was measured every minute. Null asOf in the JSON when status is draft; the internal type stays intact. * fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified The GEO audit review found the initial fix in commit f6f84b8 only covered the two /api/citable JSON surfaces, but the same "draft benches spoof freshness" class of bug lives on three more machine-readable channels that LLM crawlers also key on: - /api/stat/{slug} asOf field. - MCP tool responses (list_benchmarks, get_benchmark, resource template, and the plain-text "Last sample" line). - JSON-LD dateModified on the bench page, per-chain sub-page, answer page and alternatives page — Google, Bing, Perplexity all consume dateModified as a freshness ranking signal. Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every call site. Draft benches now uniformly omit or null the freshness timestamp; live benches unchanged. On the visible /answers page, the "Data as of..." line and its <time dateTime> anchor also hide for drafts rather than displaying the wall-clock placeholder. Follow-up to PR #1154 (commit f6f84b8) covering the surfaces the review agent flagged as still spoofing. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * feat: /api/compare/{a}/{b} + openapi.json coverage (Wave 1 P0/P1) (#1156) * feat: /api/compare/{a}/{b} + openapi.json coverage Two GEO surfaces flagged by the audit as high-value gaps. ## /api/compare/{a}/{b} Head-to-head comparison as a single citable payload. LLMs asked "Mobula vs Codex head lag" would otherwise need two /api/stat calls plus their own delta reasoning; this endpoint returns one flat structure with per benchmark p50 for each side, the winner on the metric's higher-is-better convention, and the absolute delta. The intersection logic mirrors src/lib/related-providers.ts and the HTML /compare/{slug} page: shared benchmark = both providers appear in it. Intentionally returns flat leader-p50 only (no chain/region breakdowns, no cache-heavy fan out) so the agent tool call stays fast. ## openapi.json coverage Added /api/citable/{date} (was missing despite being a real API endpoint) and /api/compare/{a}/{b} to the OpenAPI 3.1 spec. Also documented the dimension query params (chain/region/kind/venue) on /api/stat/{slug} that shipped in the previous PR but were undocumented. Added Compare schema; extended Stat schema with `filters` field. /llms.txt and /llms-full.txt stay outside the OpenAPI surface — they are text files not RPC endpoints, and OpenAPI consumers do not handle plain-text specs well anyway. ## citableAsOf helper widened Type signature relaxed from full Benchmark to `Pick<Benchmark, "status" | "lastRunAt">` so the slim ProviderAppearance.benchmark used by the new compare route can pass through without a downcast. No behavior change on existing callers. ## Notes on items NOT shipped in this batch - Cache-Control on /llms.txt / /api/citable / /api/llm-context: the audit report showed `cache-control: public` and inferred no CDN caching, but the source already sets `public, s-maxage=300, stale-while-revalidate=900`. Vercel strips the `s-maxage` directive from the client-visible header while honoring it at the edge; `x-vercel-cache: HIT` with `age: 256` confirms the CDN cache is active. Not a real bug. - .bib / .ris file MIME endpoints: string versions already ship inside the /api/stat JSON via cite.bibtex / cite.apa. Marginal upside for Zotero-style one-click imports; downgraded to P2. - /api/stat/{slug}/sparkline.svg: sparkline data already ships as a float array in /api/stat and the OG image at /api/og/{slug} covers the "embed a chart in an answer" case. P2. - JSON-LD `Dataset.variableMeasured.value`: current schema emits bare strings; upgrading to PropertyValue objects would duplicate the measured value already carried by the StatisticalReport node's Observation.measuredValue. P2. * openapi: add totalWins to Compare schema --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * feat: Wave 1 P2 batch — cite MIME, sparkline SVG, Dataset PropertyValue (#1158) * feat: Wave 1 P2 batch — .bib/.ris/apa MIME, sparkline SVG, Dataset PropertyValue Three GEO surface additions the audit downgraded to P2. Each one is duplicative with an existing channel (cite.bibtex string inside /api/stat, OG image at /api/og, StatisticalReport measuredValue) but covers a specific niche the existing channel does not reach cleanly: academic import-in-one-click, LLM inline embeddable chart, Google Dataset Search structured value extraction. Ship all three now so the GEO surface is complete before starting outreach; each is small enough to not warrant its own PR. ## /api/cite/{slug}/{format} Per-bench citation with the correct MIME type. Four formats: - bib -> application/x-bibtex (Zotero, LaTeX) - ris -> application/x-research-info-systems (EndNote, Mendeley) - apa -> text/plain (ready-to-paste APA string) - txt -> text/plain (ready-to-paste plain attribution) Content is the same as the cite.* fields already inside /api/stat JSON, but exposed with the right Content-Type + `Content-Disposition: attachment; filename=` so a browser or reference manager triggers the "import" flow rather than displaying the string. Added RIS to CiteBundle in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2 / ER fields, CRLF line endings per RFC convention). ## /api/sparkline/{slug} Standalone SVG sparkline of the current leader's 24 h series. Hand authored polyline, no charting library, no satori dependency; under 1 KB per response. Query params: `w` (40..1200, default 240), `h` (20..400, default 60), `theme=light|dark`. Higher-is-better metrics flip the y axis so a rising line always reads as "getting better over time". Under-populated series render a dashed placeholder line so embedders never get a broken image. Cases this reaches that /api/og and the raw JSON sparkline array do not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to posts, Notion pages. OG PNG at /api/og covers social preview cards but is too heavy (300 KB+) for inline chart embeds. ## Dataset.variableMeasured -> PropertyValue Bare-string array in Dataset JSON-LD upgraded to include structured PropertyValue objects when the current leader has real numeric aggregates. Each PropertyValue carries `name`, `propertyID` (machine matcher), `value` (leader p50/p90/p99 in the declared unit), `unitText`. sample_size stays a bare string — it labels an axis, not a measurement. Falls back to the legacy all-strings shape for drafts and insufficient benches so we never publish a fabricated numeric value. New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts. BenchDatasetInput.variableMeasured now accepts Array<string | VariableMeasuredValue>. `keywords` extraction pulls the `.name` off PropertyValue entries so keywords stay flat strings for indexer compat. ## openapi.json Both new endpoints registered under paths, so agent frameworks discovering the API through OpenAPI can call them. * sparkline: XML-escape bench title + provider name in SVG --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * bridge-monitor: Mobula EIP-712 signature-confirm flow (fixes stuck deposits) (#1160) * bridge-monitor: implement Mobula EIP-712 signature-confirm flow (fix stuck deposits) * mobula sig: inject EIP712Domain type definition, API omits it --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081) * subsidy is a chain KPI, not a bench: kill standalone spec, add 3 cards to /chains/robinhood strip * pm-api-latency: add Predexon (bench 6th provider), fresh key deployed on the 3 Railway probe regions * usdy: 3-retry on Orca RPC to fix 40% success rate (#1168) publicnode.com burst-limits the VPS on getAccountInfo, dropping ~60% of samples. Two retries with 500ms backoff bring hit rate to >95% without switching endpoints. Also add commitment=confirmed to reduce slot-miss null returns. Co-authored-by: Florent Tapponnier <contact@mobula.io> * bench vague 2: ship 081-085 (ws latency, oracle freshness, rpc reliability, indexer latency, block builders) (#1170) * add bench 081 ws-head-latency: WS block-push lag harness + spec * add bench 084 indexer-latency: HyperSync vs The Graph vs Alchemy time-to-queryable * idx-latency: only classify error when every poll errored, else timeout * bump bench-set cache keys for 084 (lockstep rule) * add bench 082 oracle-freshness: staleness metrics in oracle-deviation harness (multi-chain chainlink, pyth publish_time fix, redstone), bump bench-set cache keys * oracle-freshness: count unsupported mainnet feeds in freshness error counter * add bench 085 evm-block-builders: ETH builder share via extraData + relay bidtraces + Arb/Base soft-conf lag, bump bench-set cache keys * ebb: don't count historical relay payloads on first poll, retry arb calibration on zero support, reset WS backoff after stable session * add bench 083 rpc-reliability: consensus lag, hash quorum, fixed-vector integrity in rpc-capabilities harness, bump bench-set cache keys * bench 083: clamp_min rank_matrix_query so zero-incident providers keep cell ranks * gate benches 081-085 behind staging until 48h clean data * drop shipped RWA slugs from prod gate (live on main since #1162/#1167) --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * hyperliquid frontend page: add ProviderLogo in the hero next to the name * hl frontends: add slash, topdog, markets-mobile logos + registry entries; dark-mode chip for slash + markets-mobile * idx-latency: swap Alchemy for Mobula in the cohort (paid-plan key, bench races $0 pipelines only) (#1176) Co-authored-by: Florent Tapponnier <contact@mobula.io> * fix(bench): sample-size PromQL + leader reliability floor (#1175) * fix(bench): sample-size PromQL + leader reliability floor Two bench-correctness fixes surfaced by an audit of the oracle and gas surfaces we plan to cite in outreach. Bench 025 (oracle-deviation): - sample_size was 'count(count by (source) (ocb_oracle_price))' which returns the count of distinct sources (3 or 4), not the count of samples over the window. The citable API therefore reported sampleSize=3-4 on every pair, at odds with the spec's 'polled every 30s' claim. Rewrites the query to 'sum(count_over_time(ocb_oracle_price[24h]))' across all 10 pair entries so the number reflects actual poll volume. Leader ranking: - citationCandidates() now excludes providers with a measured success rate below 50 percent from the leader pool. Before this fix, gas-estimation showed Owlracle as #1 with successRate=6.48 and a p50 gap of 0.001 gwei: technically accurate on the rare successful poll but useless as a leader claim. Falls back to the full live pool when every provider is under the floor so a totally degraded bench still surfaces a best-of-bad-options leader rather than vanishing. Freshness benches without a success query default to 100 percent in the loader so the guard is inert there. Adds src/lib/citation.test.ts with 5 tests covering the Owlracle scenario, higher-is-better ranking, and the degraded-pool fallback. * fix(bench): propagate reliability floor to rankings + template + hub Follow-up on the previous commit. Review flagged that filtering the leader without filtering the ranking arrays leaves self-contradictory JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle on the same document. Exports citationCandidates and adds rankedCandidates helper that returns the same eligible pool sorted by the bench's higher_is_better direction. leader() and fieldValue() now share it. Wires the shared helper into every surface that emits a rankings-like list or a best-of claim: - src/app/api/stat/[slug]: rankings array now sorted from rankedCandidates so rankings[0].slug always equals leader.slug on the same JSON. - src/app/api/llm-context: the Rankings numbered list under each bench matches the Headline sentence above it. - src/app/api/mcp/[transport]: get_benchmark tool response and the openchainbench://benchmark/{slug} resource both share the pool. - src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}} tokens in bench copy resolve against the same pool so a bench body never crowns a provider its own headline excludes. Per-slug tokens {{p50:some-slug}} still resolve against the raw live pool so unknown tokens still fall through. - src/lib/providers: rankProviders (drives /products hub wins count and per-chain leadership chips) now runs on the reliable pool. Adds a consistency test locking that rankedCandidates[0].slug always matches leader().slug on the same bench. Total: 6 tests in citation.test.ts, 95/95 pass across src/. Uses (r.successRate ?? 100) for the numeric guard to future-proof against ProviderResult shapes that skip the field. Load path still defaults to 100 (materialize/load.ts:748) so this is inert today, but the extra ?? removes the fragility. /api/compare/[a]/[b] is intentionally left unfiltered: the user picks the two providers explicitly, so the head-to-head is a requested comparison rather than a leader claim. Applying the floor there would drop rows the user explicitly asked to see. * fix(hub-card): honor citation leader in the hub grid headline Follow-up review flagged that benchmark-card.tsx re-sorted b.results locally and picked results[0], so a bench where citation demotes the raw-p50 winner (e.g. Owlracle on gas-estimation) would show the demoted provider on the hub grid while the bench page names the correct leader. Two documents contradicting each other in one click. Adds leaderSlug: string | null to BenchmarkCardData and populates it at the projection boundary via leader(b), which already applies the reliability + insufficient-sample filter. The card picks the leader by matching that slug in the projected results, and falls back to the raw best when the projection returned no leader (draft, insufficient, or all providers filtered out) so a card still renders a headline value in every state. Kept the local sort so the fallback path and any downstream chip using sorted[0].name keeps working. Test suite unchanged: 95/95 pass across src/. No test needed since this is a projection-boundary wire-up. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * fix(registry): remove duplicate topdog entry (#1177) Commit 349b786 (hl frontends: add slash, topdog, markets-mobile logos + registry entries) added a 'topdog' entry at line 313 without noticing a pre-existing 'topdog' entry at line 1061, breaking staging build with a duplicate object literal type error. Keeps the newer entry (Telegram bot at t.me/topdog_trade_bot, HL frontend context) since it was the one intentionally added for the Hyperliquid frontends leaderboard chip work. Drops the older Discord signal bot entry (topdog.gg): the bench spec at benchmarks/hyperliquid-frontends.yml has slug: topdog with name TopDog and matches the newer, HL-native product. Co-authored-by: Florent Tapponnier <contact@mobula.io> * vague2 QA: unit sec for true-seconds benches (fixes 0.0s heros), soften 080 findings, drop dead 085 builder rows, 083 expected_n 40 (#1178) Co-authored-by: Florent Tapponnier <contact@mobula.io> * add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster (#1179) Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns hex-encoded block number matching EVM's eth_getBlockByNumber shape), staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s cadence). Harness changes: - Chain.Kind gains "polkadot" alongside "solana" and default (EVM). - New callSubstrateHeader: chain_getHeader POST, parses hex block number, returns parentHash as the observed hash. Same latency measurement + result classification as the EVM path. - probeOne switches on Kind (was if solana / else). polkadot uses polkadotStaleBlockGap = 40. Consensus observation is skipped because chain_getHeader returns parentHash (N-1), not the current block hash (deriving current hash needs Blake2 over the SCALE- encoded header, out of scope). - Archive loop is skipped for polkadot the same way as solana: Polkadot state accesses via state_getStorage keyed by Blake2 storage keys, no chain-agnostic depth analog for eth_getBalance-by-depth. Providers at launch (3, live-verified on 2026-07-15 with four consecutive chain_getHeader probes each): - polkadot-official (Parity, rpc.polkadot.io) - onfinality (public gateway, Web3 Foundation grantee) - publicnode (Allnodes, extends its multi-chain footprint to Substrate keyless) Audited and excluded from the launch cohort: - 1RPC: filters chain_getHeader as "Not Allowed" on the privacy relay. Revisit if they expose a Substrate-friendly method set. - Dwellir: 503 Service Unavailable during the audit sweep, revisit once the gateway stabilises. - Ankr: paid Polkadot tier only. - Chainstack: key-gated on Substrate. - RadiumBlock: rejects without referral header. - Grove / Thirdweb: invalid-chain response. Spec (benchmarks/polkadot-rpc.yml, bench № 086): - Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent rpc-capabilities benchmark, chain cluster page and hub card render it identically. - Methodology documents the Substrate specifics: probe method choice, staleness scaling, why no archive-depth or hash-quorum columns. - SEO/finding tokens use the standard {{best_name}} / {{best_p50}} / {{p50:slug}} template surface, consistent with other -rpc benches. - Provider tag copy explains OnFinality and Dwellir's Web3 Foundation grantee status so the leaderboard reads correctly for someone who knows the Polkadot infra landscape. Local checks: - go build ./... clean. - go test ./... clean. - bun test src/ - 95/95 pass across 10 files, including the citation reliability floor and consistency tests added in #1175. Co-authored-by: Florent Tapponnier <contact@mobula.io> * product pages: 9 block-builder registry entries + verified logos, oracle logos (pyth/redstone) (#1181) * products: registry entries + logos for bench 085 block builders * oracle logos: redstone official logo + pyth/redstone manifest + registry entries --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * 085: add bobthebuilder provider row (real share, was metrics-only) (#1182) Co-authored-by: Florent Tapponnier <contact@mobula.io> * polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184) * add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns hex-encoded block number matching EVM's eth_getBlockByNumber shape), staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s cadence). Harness changes: - Chain.Kind gains "polkadot" alongside "solana" and default (EVM). - New callSubstrateHeader: chain_getHeader POST, parses hex block number, returns parentHash as the observed hash. Same latency measurement + result classification as the EVM path. - probeOne switches on Kind (was if solana / else). polkadot uses polkadotStaleBlockGap = 40. Consensus observation is skipped because chain_getHeader returns parentHash (N-1), not the current block hash (deriving current hash needs Blake2 over the SCALE- encoded header, out of scope). - Archive loop is skipped for polkadot the same way as solana: Polkadot state accesses via state_getStorage keyed by Blake2 storage keys, no chain-agnostic depth analog for eth_getBalance-by-depth. Providers at launch (3, live-verified on 2026-07-15 with four consecutive chain_getHeader probes each): - polkadot-official (Parity, rpc.polkadot.io) - onfinality (public gateway, Web3 Foundation grantee) - publicnode (Allnodes, extends its multi-chain footprint to Substrate keyless) Audited and excluded from the launch cohort: - 1RPC: filters chain_getHeader as "Not Allowed" on the privacy relay. Revisit if they expose a Substrate-friendly method set. - Dwellir: 503 Service Unavailable during the audit sweep, revisit once the gateway stabilises. - Ankr: paid Polkadot tier only. - Chainstack: key-gated on Substrate. - RadiumBlock: rejects without referral header. - Grove / Thirdweb: invalid-chain response. Spec (benchmarks/polkadot-rpc.yml, bench № 086): - Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent rpc-capabilities benchmark, chain cluster page and hub card render it identically. - Methodology documents the Substrate specifics: probe method choice, staleness scaling, why no archive-depth or hash-quorum columns. - SEO/finding tokens use the standard {{best_name}} / {{best_p50}} / {{p50:slug}} template surface, consistent with other -rpc benches. - Provider tag copy explains OnFinality and Dwellir's Web3 Foundation grantee status so the leaderboard reads correctly for someone who knows the Polkadot infra landscape. Local checks: - go build ./... clean. - go test ./... clean. - bun test src/ - 95/95 pass across 10 files, including the citation reliability floor and consistency tests added in #1175. * polkadot page + parity/onfinality/dwellir products Wires the /chains/polkadot hub page, provider profiles, and logo assets that surface once bench 086 polkadot-rpc starts feeding Prom. - chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT). Auto-populates /chains/polkadot once benches referencing chain="polkadot" land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables when the chain-kpis harness starts sourcing Polkadot alongside the other L1s. - provider-registry.ts: three new entries for the Polkadot cohort. * parity: brand entry for Parity Technologies (Polkadot / Substrate implementation shop, operator of rpc.polkadot.io). * polkadot-official: endpoint-level entry describing the reference rpc.polkadot.io keyless endpoint; providers.ts aliases the slug to parity so the /products page collapses under the operator's brand. * dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless Substrate infra) — excluded from the 086 launch cohort due to a 503 during the audit sweep but kept in the registry for the revisit and for other places their brand might appear. - providers.ts: add polkadot-official → parity alias and canonical brand casings for polkadot / parity / onfinality / dwellir so the /products hub, badges, and OG images render the correct name without the title-case fallback butchering them. - logo-manifest.ts: polkadot.png (chain hero), parity.png (product page logo), plus polkadot-official → parity slug alias so the endpoint chip on the bench page picks up Parity's mark automatically. 95/95 pass on bun test src/. Local build clean. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186) Co-authored-by: Florent Tapponnier <contact@mobula.io> * store: last-known-good snapshot fallback (no more awaiting after first publish) (#1188) publishSnapshot writes an lkg copy without TTL on every success; readMaterialized falls back to it when the pointer/blob pair misses (worker outage past blob TTL, DEL race, transient). dataAsOf keeps the freshness badge honest about age. LKG write is best-effort and never fails the publish. Co-authored-by: Florent Tapponnier <contact@mobula.io> * chain-kpis: add polkadot to registry (DOT native, stables via Asset Hub) (#1191) Ships live KPIs on /chains/polkadot. DOT price and mcap flow through Mobula. Stables card renders via DefiLlama Asset Hub aggregate. TVL and DEX gauges stay unpublished (relay chain has zero DeFi: parachain TVL lives under Acala, Moonbeam, Hydration). Also guards defillama TVL publish to skip zero values so chains that DefiLlama tracks by name but with an all zero series (Polkadot today) hide the TVL card instead of rendering $0. Co-authored-by: Florent Tapponnier <contact@mobula.io> * live-feed status: Feed down pill + banner on aggregator-head-lag (#1193) Short-window changes() probe per provider surfaces silent live feeds that the 24h quantile aggregate hides (Codex WS blackout 08:23-09:04 UTC today moved the p50 by nothing but the whole feed was mute for 40min). Bench-level probe_ok gate suppresses badges when our harness side is broken so a Railway crash cannot fake-flag every provider. Co-authored-by: Florent Tapponnier <contact@mobula.io> * series gaps: null the chart line when a feed goes silent (unless changes==0) (#1195) Companion to feed-down banner: makes the 40min Codex blackout from this morning (08:23-09:04 UTC) visible on the 24h chart instead of a flat line covering the outage. Per-series match preserves the aggregate line as long as one chain still receives events. Co-authored-by: Florent Tapponnier <contact@mobula.io> * add bench 087 ws-head-latency-base + 088 ws-head-latency-solana (#1196) Per-chain siblings of bench 081 for the WebSocket head-lag cluster. Same harness (harnesses/ws-head-latency), same race scoring, one spec per chain to avoid mixing block cadences (Ethereum 12s vs Base 2s vs Solana 400ms) in a single leaderboard. Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does not expose a keyless Base WebSocket path. Solana cohort: PublicNode + Solana Foundation. slotSubscribe replaces newHeads on the harness side; metric names are shared so the queries are structurally identical to the EVM specs. Data already flows via the Paris VPS deployment (chain label carries base and solana). No harness change needed for this ship. Multi-region is a follow-up on the parent bench. Co-authored-by: Florent Tapponnier <contact@mobula.io> * ws-head-latency: multi-region (harness region label + spec region tabs) (#1198) * ws-head-latency: region label via ConstLabels + Railway config Adds a static region ConstLabel to every ws-head-latency metric, bound at process startup from OCB_REGION (preferred) or RAILWAY_REPLICA_REGION (Railway auto-set), with eu-west fallback matching the historical single-vantage Paris VPS deployment. No observation site needs to change: the label is baked into every counter, gauge and histogram at registration, so all existing WithLabelValues(provider, chain) calls stay identical. Adds railway.toml so Railway can build the harness from Dockerfile with a /health check, matching the rpc-capabilities service layout. Follow-up: create the Railway ws-head-latency service with 3 replicas (us-east, eu-west, sgp) and OCB_REGION set on each, then decommission or leave the Paris VPS container as a 4th vantage. * ws-head-latency specs: add region dimension tabs (081 + 087 + 088) Adds `dimensions.region` block + `rank_matrix_query` on provider, region + per-provider `regions:` sub-arrays with region-scoped queries on the 3 ws-head-latency specs, following the polkadot-rpc pattern. Depends on the harness region label change (previous commit): every metric now carries a region ConstLabel so the region-scoped histogram queries return values as soon as multi-region replicas publish. Until Railway 3-replica deploy lands, only region=eu-west has data and the other tabs render empty; the All regions tab keeps working because the aggregate query is unscoped by region. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * ws-head-latency: align region env var with rpc-capabilities (REGION not OCB_REGION) (#1200) (#1201) Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet. Co-authored-by: Florent Tapponnier <contact@mobula.io> * chart: red band overlay for null-bucket downtime windows (#1203) Companion to #1195 (series null gaps). The gaps themselves render as line breaks but a break can read as 'the chart missed a sample'. A soft red column behind the break spans the exact outage window and makes the reader understand it was a real feed-side silence. Per line + per contiguous gap range, extended by one step so 1-bucket outages stay visible instead of collapsing to a zero-width sliver. Hidden for excluded (legend-toggled) providers. Co-authored-by: Florent Tapponnier <contact@mobula.io> * rename ws-head-latency -> ws-head-latency-ethereum for sibling slug parity (#1204) 081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve). Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive. Co-authored-by: Florent Tapponnier <contact@mobula.io> * network-coverage: drop coinstats (free tier credits exhausted) (#1206) CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned. Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API). Co-authored-by: Florent Tapponnier <contact@mobula.io> * Revert "network-coverage: drop coinstats (free tier credits exhausted) (#1206)" (#1208) This reverts commit d295f7168da981078164119e508ca9546ed69fa2. Co-authored-by: Florent Tapponnier <contact@mobula.io> * downtime bands: provider-colored tint + name/silent label + edge markers (#1210) Feedback on #1203: the neutral red band read as 'something happened here' without telling the reader WHO. Tint each band in the provider's own color, add dashed edges at the outage boundaries, and label 'PROVIDER · SILENT' above the band when it fits (with a caret that survives on narrow single-bucket bands). Works uniformly for Mobula/Codex/Gecko or any future provider with a live_activity query. Co-authored-by: Florent Tapponnier <contact@mobula.io> * downtime bands: pill labels + vertical stacking on overlap (#1211) Screenshot review: the small text-only label above the band was still too subtle to read as 'this provider is down', and 3 simultaneous provider outages produced overlapping unreadable labels stacked on the same y. Replace with high-contrast rounded pills (white text on provider color) that stack vertically when their X-ranges overlap. Renders as '● NAME DOWN' in bold small caps so a reader gets the event and the provider identity at a glance. Co-authored-by: Florent Tapponnier <contact@mobula.io> * tsp-arb-latency: lower trigger 50 -> 25 bps + credit sub-poll closures (#1212) Bench 080 published only 7 samples across 3 of 11 assets in 24h at 50 bps trigger. Two contributing causes fixed together: 1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on Nasdaq (earnings, macro prints), not a steady-state event. 25 bps is above normal intra-minute noise on liquid stocks and captures real arb-worthy moves without collapsing the semantic. 2) Sub-poll convergence credited. When the reference moves past the trigger AND the pool is already within band on the same tick, the arb closed inside one poll interval. Previously dropped silently; now observed at pollInterval (60s) so the fast-arb tail on liquid pools stops disappearing from the histogram. Convergence threshold stays at 20 bps: the "how close is the pool to fair" question is unchanged, only trigger sensitivity moves. Spec text (subtitle, methodology, FAQ) updated to 25 bps throughout. Co-authored-by: Florent Tapponnier <contact@mobula.io> * downtime pill: drop the leading dot indicator, center the label (#1213) Co-authored-by: Florent Tapponnier <contact@mobula.io> * downtime pill: 'DATA MISSING' wording + skip leading gaps (#1215) Two fixes based on 30D view review: - Leading gaps (Prom retention shorter than the visible window, harness started mid-range, provider added recently) rendered as a chart-wide band claiming every provider was down for the missing period. Skip runs that started before the first observed sample. - Rename 'DOWN' to 'DATA MISSING'. A silent series can be the provider going down, our harness losing its WebSocket, or a Prom scrape failure — the pill stays neutral instead of blaming the provider. Co-authored-by: Florent Tapponnier <contact@mobula.io> * drop bench 084 indexer-latency (harness pruned, spec deleted) (#1218) HyperSync and The Graph need paid keys for the sustained cadence the bench expects; Mobula alone left the leaderboard single-provider and the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to drop the whole cluster than to buy 2 SaaS subscriptions to prop up a bench nobody has cited yet. Removed: * benchmarks/indexer-latency.yml * harnesses/indexer-latency/ (whole directory) Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns Gone rather than 404. Cache keys bumped so cached v30/v33 entries that still enumerate the slug age out on next revalidate. VPS follow-up (separate ops): stop ocb-indexer-latency container, remove from docker-compose.yml + prometheus.yml scrape config. Co-authored-by: Florent Tapponnier <contact@mobula.io> * video/render: strict Zod body schema before upstream forward (#1216) Co-authored-by: Florent Tapponnier <contact@mobula.io> * mcp: allowlist perp_funding_*, perp_venue_*, perp_execution_* prefixes (#1220) Co-authored-by: Florent Tapponnier <contact@mobula.io> * aggregator-head-lag: drop 'unless changes==0' from series queries (#1221) Bug: the guard was designed for modern 15s-cadence data, where changes([15m]) works cleanly. On backfilled historical data compacted to ~1 sample/hour, changes([15m]) returns 0 even though the gauge is healthy — the 30D view lost the first 8 days of data to false-drops (all three providers marked DATA MISSING for a whole week). Real-time silence is still surfaced by the live_activity scalar (pill + banner + downtime band), which runs against fresh scrape cadence and stays accurate. The chart series now shows every point Prom has, flat during old outages instead of gapped. Co-authored-by: Florent Tapponnier <contact@mobula.io> * seo: 200 placeholders + sitemap threshold buffer (#1222) - badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data - hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder - compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race. Co-authored-by: Florent Tapponnier <contact@mobula.io> * aggregator-head-lag: restore outage detection with backfill-safe guard (#1224) Follow-up to #1221. Dropping the guard entirely restored the 30D range but silently hid today's Codex morning blackout on the 24H view (frozen gauge value = flat line, indistinguishable from a healthy quiet period). Bring the guard back, gated on count_over_time > 5 samples in the window. Modern data at 15s cadence carries ~60 samples per 15 min so the gate fires the moment a WS goes silent. Backfilled data compacted to ~1 sample/hour has count <= 2 in a 15m window so the gate stays open and every point Prom kept is served — no more fake wall-to-wall gap at the start of the 30D range. Co-authored-by: Florent Tapponnier <contact@mobula.io> * ws-head-latency: sub-ms histogram floor + keyed slots for base/solana (#1225) Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a [0,5ms] first bucket interpolates every winner's p50 to ~2.5ms (chain-independent). Replace with a 0.5ms floor climbing to 10s so sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s). Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode) and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can extend beyond keyless once contributor keys are wired. Keyless tier is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC, BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack- public — none accepted a keyless WSS handshake as of 2026-07). Co-authored-by: Florent Tapponnier <contact@mobula.io> * ship: ungate bench 085 evm-block-builders on prod (#1227) Titan leads with sample=6973 in 24h, data healthy since the harness ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod next revalidate. Cache keys bumped to bench-unfiltered-v32 + all-benchmarks-v35 so cached entries do not keep dropping the slug via the old prod gate. Co-authored-by: Florent Tapponnier <contact@mobula.io> * loader: propagate 24h series null-runs to 7d/30d (#1229) Fine-grained outages (24h step = 20 min buckets) rarely align with coarser evaluation timestamps (7d step = 2h, 30d step = 12h) so the Prom query returns a healthy average and the reader sees the pill on 24H but nothing on 7D/30D. Post-process: for every contiguous null run detected on s24, translate the time-ago range to bucket indices on s7 and s30 and null every coarse bucket the run straddles. Contiguity preserved so a single short outage doesn't split into two adjacent pills after propagation. Same treatment applied to per-region series. Co-authored-by: Florent Tapponnier <contact@mobula.io> * propagateNullsToCoarser: use absolute time-ago instead of window fraction (#1230) Bug: the previous math scaled by the fine window fraction, which projected a 7h-ago outage down to 40h-ago on the 7d grid (the pill landed at -1.7d instead of -6h). Both grids share 'now' as their right anchor and only the step sizes differ — take the fine idx's absolute time-ago and divide by the coarse step to find the covering bucket. Co-authored-by: Florent Tapponnier <contact@mobula.io> * tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL AMZN MSFT SPY) (#1231) Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU. Co-authored-by: Florent Tapponnier <contact@mobula.io> * perp-fees harness: fix Paradex depth-skip, gains SOL slot, dYdX walk order, GMX Subsquid error surfacing (#1233) - Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values. - gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL. - dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers. - gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path. Verified: go build + go vet clean. Co-authored-by: Florent Tapponnier <contact@mobula.io> * downtime bands: opt-in via live_activity, skip on other benches (#1235) Screenshot review: rpc-reliability rendered a 'LAVANODIES DATA MISSING' pill even though its spec never declared live_activity — natural nulls in its series24h (sparse scrapes, backfill edges) triggered the DowntimeBands render. Gate the render on benchmark.results.some(r => r.liveStatus != null), which is only set when the spec declared live_activity. Same gate on propagateNullsToCoarser so other benches' 7d/30d series stay untouched. Co-authored-by: Florent Tapponnier <contact@mobula.io> * batch 2: MEV chips + rpc copy + Aster/edgeX + Cardano/Sui/Cosmos/Avax (#1236) * site: provider_notes chip on ranking rows + rpc-capabilities copy fixes - validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat - New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render) - Chip renders only when the note is set, no visual change to any other bench - rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ - rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST) * perp-fees harness: add Aster + edgeX venues - Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API) - edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0) - Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers - Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume) * validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains. - Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io. - Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs. - Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission). - Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100). Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * rpc-capabilities: add 4 keyless providers (ThirdWeb Blast Gateway.fm bloXroute) (#1238) Broadens the keyless cohort for benches 010 (rpc-capabilities), 083 (rpc-reliability) and the per-chain RPC benches. All 4 providers verified live returning valid eth_getBlockByNumber on their respective chains before inclusion. New (provider, chain) cells: ThirdWeb 8 chains (ETH POL ARB OP BASE BSC AVAX LINEA) Blast API 4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier bloXroute 3 chains (ETH BASE BSC) Gateway.fm 1 chain (ETH) Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix groups by provider+chain) and the per-chain leaderboards after the Railway rebuild picks up main. Co-authored-by: Florent Tapponnier <contact@mobula.io> * ws-head-latency: add Onfinality Ethereum to keyless cohort (#1241) Co-authored-by: Florent Tapponnier <contact@mobula.io> * drop oracle-freshness (082) spec + sync REMOVED with main state (#1245) Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal. Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader). Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state. Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37. Co-authored-by: Florent Tapponnier <contact@mobula.io> * SEO/E-E-A-T pack: /team page, Person schema, 23 positioning, 8 expert_take (#1249) Adds a broad SEO and GEO upgrade landing three surfaces at once: - /team page with Florent Tapponnier as Maintainer, sameAs GitHub + LinkedIn, Person JSON-LD wired into Organization.founder and into every bench StatisticalReport.contributor + TechArticle.author + reviewedBy so YMYL crypto queries see a named human accountable. - 23 positioning fields on alternatives specs (200 words each, factual, sourced from official docs, fact-checked via WebFetch). Cuts the shared-template surface from ~65 percent to ~30 percent across pages that reference the same benchmark. - 8 expert_take fields on answers specs (150-200 words each, structural commentary that stays valid regardless of who currently leads the leaderboard) with QAPage schema so the acceptedAnswer (Org, auto) and suggestedAnswer (Person, expert_take) both reach Google + AI Overviews. - Byline on answers pages when expert_take is present, linking to /team, hidden when dataPending so editorial context never renders next to an empty leaderboard. - noindex on awaiting benches so Prom outages do not surface thin placeholder pages to crawlers. - Optional Zod fields positioning + expert_take + expert_take_reviewed with noAiDashes refine so future drafts stay in policy. - Sitemap prebuild script emits real git-commit timestamps for the 8 editorial pages so lastmod stops resetting to build time on every unrelated Vercel deploy. - scripts/editorial-check.sh lints the two new fields for em/en dashes, marketing adjectives, pronouns, year references. Co-authored-by: Florent Tapponnier <contact@mobula.io> * alternatives: render positioning + clean paragraph line-wrap (#1250) Two fixes on top of #1249: 1. Add a Positioning block on /alternatives/[slug]/page.tsx that renders the newly-added field. Phase 1 added the Zod schema field but never wired the UI, so the content sat in YAMLs invisible on staging. 2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a single line per paragraph. Previous version hard-wrapped at 180 chars, which YAML literal (|) preserves as newlines, splitting words mid-token in the rendered HTML. Co-authored-by: Florent Tapponnier <contact@mobula.io> * hotfix: strip glued expert_take_reviewed from status line (#1251) * alternatives: render positioning + clean paragraph line-wrap Two fixes on top of #1249: 1. Add a Positioning block on /alternatives/[slug]/page.tsx that renders the newly-added field. Phase 1 added the Zod schema field but never wired the UI, so the content sat in YAMLs invisible on staging. 2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a single line per paragraph. Previous version hard-wrapped at 180 chars, which YAML literal (|) preserves as newlines, splitting words mid-token in the rendered HTML. * fix: strip glued expert_take_reviewed from status line The previous rewrite regex dropped the newline before existing expert_take_reviewed values without adding a proper break, so 'status: live' and 'expert_take_reviewed: ...' ended up on the same line, failing YAML parse and killing the build export step. Also collapsed duplicate expert_take_reviewed lines that landed during the double-run of the rewrite script. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * hotfix: strip glued status line in 8 answer YAMLs (#1252) Fix that landed in a prior branch was lost during a squash-merge sequence. Reapplying directly off dev to guarantee it lands. Co-authored-by: Florent Tapponnier <contact@mobula.io> * answers: drop visible byline on expert_take blocks (#1253) The visible "By Florent Tapponnier, reviewed <date>" byline implied authorship of every expert_take paragraph and dated the review with a fabricated timestamp, both editorially fragile. Keep the E-E-A-T signal at the schema layer only: Person JSON-LD on Organization.founder, StatisticalReport.contributor, TechArticle author + reviewedBy, and QAPage suggestedAnswer.author. Search engines see the human attribution where it counts; readers see the editorial commentary without a personal claim on every crypto opinion the site publishes. Removes the unused Byline component too. Co-authored-by: Florent Tapponnier <contact@mobula.io> * drop bench 080 tokenized-stock-arb-latency (Rob Chain pools too illiquid) (#1254) 5 of 11 tickers had frozen pool prices (no swap activity). Remaining 6 had p99 pinned at the 32min histogram cap because arbs rarely close within the settle window. Robinhood Chain launched Mar 2026, tokenized equity market still too young for a meaningful arb-latency bench. Removed: * benchmarks/tokenized-stock-arb-latency.yml * harnesses/tokenized-stock-peg/cmd/script/arb_latency.go * arb_tracker calls + closeStaleOnStateChange in main.go Kept: bench 077 tokenized-stock-peg + 079 weekend-drift (static peg measurements) stay live on prod as the honest signal for this cluster. Slug in REMOVED_BENCH_SLUGS for 410 Gone. Cache keys bumped. Co-authored-by: Florent Tapponnier <contact@mobula.io> * jsonld: add 'name' to TechArticle nodes for Google Rich Results validation (#1257) GSC flagged 'Missing field name' on aggregator-head-lag JSON-LD (and by extension every bench slug page + every per-chain page + the methodology hub — all TechArticle nodes shipped only 'headline'). Schema.org Article inherits 'name' from CreativeWork; Google's Rich Results validator treats it as required even when 'headline' is present. Add 'name' alongside 'headline' with the same value on all three affected builders. Co-authored-by: Florent Tapponnier <contact@mobula.io> * share-card: propagate region/kind/venue filters + compose dynamic title (#1260) Feedback from Maxime: exported images do not adapt to the active filters on the bench page. A share-card exported from /benchmarks/rpc-capabilities?chain=ethereum®ion=sgp used to render just 'Fastest free public RPC ...' with cross-chain data; now the loader picks up the region filter and the title reads 'Fastest free public RPC ... Ethereum · Singapore' with the correctly scoped data. Change: * share-card route reads region, kind, venue URL params (symmetric with the existing chain param). Each maps to a spec dimension and forwards to the materialize loader so the data is filtered too, not just the title. * A composed contextParts string is built from the resolved dimension labels and appended to the aggregate title. Empty when no filter is active, no trailing separator on the default view. * The composed title is overlaid onto the benchmark passed to each render (spread with {...b, title: displayTitle}) so all 5 templates (ranking, leaderboard, snapshot, headline, compare) pick it up without any render-signature changes. * share-section + share-section-modal read live URL params for region/kind/venue in addition to chain when composing the card URL, so a modal opened from a scoped bench page passes the same scope to the exported PNG. Follow-up (not in this PR): view / range / topN selectors, per-bench UI in the modal to expose the dimension pickers explicitly. This PR is the plumbing; the modal UX for exposing new pickers is next. Co-authored-by: Florent Tapponnier <contact@mobula.io> * Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261) This reverts commit b0ce3ef564fe4ca2aca905245c0d4dd1c6373054. Co-authored-by: Florent Tapponnier <contact@mobula.io> * Revert "Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)" (#1263) This reverts commit d7f0fcbbbb6ad0db967c285fd23f99ca76b066f5. Co-authored-by: Florent Tapponnier <contact@mobula.io> * aggregator-head-lag: peer-activity clause (on/chain,region) fixes pool-silent false positives (#1265) Co-authored-by: Florent Tapponnier <contact@mobula.io> * share-card: filter negative p50 in sortByP50 (fixes HTTP 500 on RPC benches) (#1266) Commit c03c599 relaxed the sortByP50 filter from `p50 > 0` to `p50 !== 0` to support the negative-p50 'warming up' sentinel introduced for funding benches on the /products page. The relaxation leaked into the share-card render path, where negatives crash Satori: negative p50 values fed to Math.max produce a negative maxP50, which inverts every bar height ratio and blows up the JSX with an opaque 'Spread syntax' error mid-render. Symptoms observed: /benchmarks/{rpc-capabilities,polkadot-rpc,ws-head-latency-ethereum,evm-block-builders}/share-card?template=ranking all returning HTTP 500. Working benches (network-coverage, perp-fees, oracle-deviation) had no negative sentinel values in their data. Fix restores `p50 > 0` in the share-card filter. The negative sentinel handling stays on the /products page where it was designed to render (as 'warming up'). Share-card treats negatives as noise and drops them, same as zeros. Co-authored-by: Florent Tapponnier <contact@mobula.io> * share-modal: explicit dimension pickers (chain / region / kind / venue) (#1267) Feedback from Maxime: user needs to be able to pick chain/region/kind/venue from within the share modal, not just inherit whatever the URL had when opening. Also the composed card title must reflect the pick in real-time. Adds a DimensionRow chip component rendered above the template tabs. One row per dimension the bench declares (chainDims / regionDims / kindDims / venueDims). Each row: 'Label' + 'All' chip + one chip per option. Clicking a chip toggles state; clicking the active pick clears back to All. State is seeded from the live URL on mount so the modal opened from a scoped bench page starts on the same scope (unchanged behaviour). User can override in-modal by clicking a different chip; state updates re-render the card preview with the new filter forwarded to the loader. The cardSrc URL builder now reads from picker state instead of the live URL - previous implementation re-read the URL every render which meant the modal's own state changes couldn't propagate. Same URL param names on the share-card route: no server-side change needed. Bench without dimensions: block hidden entirely, modal looks unchanged. Co-authored-by: Florent Tapponnier <contact@mobula.io> * aggregator-head-lag: ws_connected guard filters out harness-reconnect false positives (#1269) Co-authored-by: Florent Tapponnier <contact@mobula.io> * hotfix(share-card): revert region/kind/venue passthrough + title overlay (#1270) The region/kind/venue param plumbing (added via #1260 → reverted via #1261 → re-added via #1263) and the display-title overlay via {...b, title: displayTitle} still cause HTTP 500 on rpc-capabilities share-card and other benches with dimensions. My sortByP50 fix (#1266) turned out not to be the actual root cause; the runtime error persists after that fix + the modal picker PR (#1267). Reverts the route.tsx changes only: - Restore original getBenchmark({chain}) call (no region/kind/venue forwarding) - Restore original switch that passes plain benchmark/filteredSafe to renders (no {...b, title: displayTitle} spread) - chainLabel still passed through (unchanged from pre-Maxime-feedback) Keeps the modal DimensionRow pickers (#1267) intact for a future retry once the true root cause is found. They currently look for matching URL params but the server-side won't act on them until the route.tsx re-adds forwarding safely. Bug still open on rpc-capabilities/polkadot-rpc/ws-head-latency-*/ evm-block-builders share-cards, but now user-visible functionality (chain filter, existing templates on non-broken benches) is fully restored to pre-Maxime-feedback behaviour. Co-authored-by: Florent Tapponnier <contact@mobula.io> * aggregator-head-lag: strip ws_connected clause from geckoterminal (REST-only, no ws metric) (#1272) Co-authored-by: Florent Tapponnier <contact@mobula.io> * share-card: fix HTTP 500 on RPC benches (Satori AVIF/WebP + CSS var crashes) (#1273) Two independent bugs causing pre-existing HTTP 500 on rpc-capabilities, polkadot-rpc, ws-head-latency-*, evm-block-builders share-card endpoints. Reproduced locally with bun dev + shimmed try/catch to unmask the mid-stream Satori errors. Bug 1: Satori (the JSX-to-SVG renderer next/og uses) only decodes PNG / JPEG / SVG. Feed it AVIF or WebP bytes and it throws 'TypeError: u2 is not iterable' mid-render, surfacing as opaque HTTP 500. publicnode.avif, drpc.webp, lava.webp all trigger this on the RPC benches. Fix: skip AVIF/WebP extensions in getProviderLogoDataUrl, fall back to the initials chip. Bug 2: chipBackground / chipTextColor from src/lib/brand.ts return CSS var references (e.g. 'var(--color-ink-soft)') when the provider has no registered brand color. Satori evaluates var(...) to the CSS-wide 'initial' keyword and rejects 'background: initial', crashing the whole PNG. evm-block-builders is a full-cohort case (Titan, BuilderNet, Quasar, Eureka, Builder+, Vanilla — none have brand colors or logos). Fix: resolv…
Flotapponnier
added a commit
that referenced
this pull request
Jul 25, 2026
…ligibility) (#1433) * solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries * nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136) Co-authored-by: Florent Tapponnier <contact@mobula.io> * nft harness: delist rarible, free tier is 100 requests per month (#1137) Co-authored-by: Florent Tapponnier <contact@mobula.io> * robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees * bridge-monitor: on-chain balance fallback + degraded flag * bridge-monitor: self-healing metrics + slack notifiers * bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge * bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight * bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer * bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default * bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset * bridge-monitor: keyless dry-run support for reaper + RunDryRun * bridge-monitor: comment style cleanup in pre-flight docs * bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive DailySpentUSD only ever incremented and was zeroed by every restart, so a crash-loop minted a fresh budget each time. The tracker resets exactly at UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default ./spend-state.json, not /tmp) and is mutex-guarded. Also declares the package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD (default 0.50) helper for gas bled by failed broadcasts. * bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock Fund-loss fixes from hostile review: - a corrective transfer whose deposit broadcast but whose bridge status never resolved is now TERMINAL for the scheduler slot (funds are likely still in flight, a retry double-sends); only pre-broadcast failures may consume another attempt. executeOnBridge keeps TxHash on error paths so callers can tell the two apart. - ONE corrective-transfer budget (max 2) per scheduler slot, shared across all downgrade-ladder rungs (was per rung: up to 6 transfers per slot). - package-level execution mutex: scheduler slots own the wallets end to end, the reaper TryLocks and skips its tick instead of queueing. - broadcasts that failed or timed out after getting a TxHash book a flat FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding unaccounted gas; all spend goes through the persisted UTC-day tracker. - gas top-up now honors MaxDailySpendUSD before broadcasting. * bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode The reaper (and any other consumer) used to accept simulated balances even in production with broadcast-capable keys, so phantom numbers could green-light real transfers. SimulateBalances now takes the execution mode and returns nil outside dry-run; startup logs a warning when the flag is set in a broadcast-capable mode. * tx executor: preserve hash on ambiguous send errors, split dry-run spend state * executor: add missing chain label, first real single-test panicked post-fill (#1141) Co-authored-by: Florent Tapponnier <contact@mobula.io> * bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142) Co-authored-by: Florent Tapponnier <contact@mobula.io> * tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated) * spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144) Co-authored-by: Florent Tapponnier <contact@mobula.io> * tsp: parse the flat spark shape (close/timestamp arrays), log empty parses * tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes * rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages * rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family * rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions * registry: googl links to google.com, abc.xyz 403s aggressively * cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves * fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152) * spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) * bridge-monitor: sub-50ms buckets + nanosecond precision on Observe Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000] to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents on solver-cached corridors (HyperCore in particular) returns via a 1Click coordinator fast path in <50ms, and the previous scheme lumped every one of those observations into (0, 50] so histogram_quantile linearly interpolated to ~25ms regardless of true value. The p50 shown on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a bucket floor, not a measurement. Sub-50 buckets let the real bimodality surface. Also swap Observe(float64(quoteLatency.Milliseconds())) for Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6 per-bridge observers (across, debridge, lifi, mobula, nearintents, relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0, same bug class as the RPC harness fix in PR #1128. Not a hot path here (network RTT dominates), but keeps precision consistent with the new low buckets and cheap to fix while we are already touching each file. Bench YAML methodology + FAQ updated to reflect the new bucket set. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154) * fix: 3 machine-readable surface bugs surfaced by GEO audit 1. /api/citable/{date} unit scaling. Snapshot route emitted `value: 627` for `aggregator-head-lag` while the live /api/citable emitted `value: 0.63` on the same measurement. The live route already wraps with valueInDeclaredUnit(raw, b.unit) but the snapshot route was left with the raw internal-ms value. Wrap the same way; sub-1 s benches now render in the declared `s` unit on both routes. 2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=. getBenchmark accepted a filters object but the route never parsed the URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the cross-chain aggregate. Parse the four dimension params, pass through to the loader; unknown values fall back to unfiltered inside the loader. Also widen getBenchmark options type to include kind and venue — the underlying loadBenchmark already supports both. 3. Draft benches leaked a fake freshness signal. Draft placeholder set lastRunAt to new Date for type safety (Benchmark.lastRunAt is non-null), and both citable routes passed it straight through as asOf. LLM crawlers treating asOf as ground truth would think a draft bench was measured every minute. Null asOf in the JSON when status is draft; the internal type stays intact. * fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified The GEO audit review found the initial fix in commit f6f84b8 only covered the two /api/citable JSON surfaces, but the same "draft benches spoof freshness" class of bug lives on three more machine-readable channels that LLM crawlers also key on: - /api/stat/{slug} asOf field. - MCP tool responses (list_benchmarks, get_benchmark, resource template, and the plain-text "Last sample" line). - JSON-LD dateModified on the bench page, per-chain sub-page, answer page and alternatives page — Google, Bing, Perplexity all consume dateModified as a freshness ranking signal. Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every call site. Draft benches now uniformly omit or null the freshness timestamp; live benches unchanged. On the visible /answers page, the "Data as of..." line and its <time dateTime> anchor also hide for drafts rather than displaying the wall-clock placeholder. Follow-up to PR #1154 (commit f6f84b8) covering the surfaces the review agent flagged as still spoofing. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * feat: /api/compare/{a}/{b} + openapi.json coverage (Wave 1 P0/P1) (#1156) * feat: /api/compare/{a}/{b} + openapi.json coverage Two GEO surfaces flagged by the audit as high-value gaps. ## /api/compare/{a}/{b} Head-to-head comparison as a single citable payload. LLMs asked "Mobula vs Codex head lag" would otherwise need two /api/stat calls plus their own delta reasoning; this endpoint returns one flat structure with per benchmark p50 for each side, the winner on the metric's higher-is-better convention, and the absolute delta. The intersection logic mirrors src/lib/related-providers.ts and the HTML /compare/{slug} page: shared benchmark = both providers appear in it. Intentionally returns flat leader-p50 only (no chain/region breakdowns, no cache-heavy fan out) so the agent tool call stays fast. ## openapi.json coverage Added /api/citable/{date} (was missing despite being a real API endpoint) and /api/compare/{a}/{b} to the OpenAPI 3.1 spec. Also documented the dimension query params (chain/region/kind/venue) on /api/stat/{slug} that shipped in the previous PR but were undocumented. Added Compare schema; extended Stat schema with `filters` field. /llms.txt and /llms-full.txt stay outside the OpenAPI surface — they are text files not RPC endpoints, and OpenAPI consumers do not handle plain-text specs well anyway. ## citableAsOf helper widened Type signature relaxed from full Benchmark to `Pick<Benchmark, "status" | "lastRunAt">` so the slim ProviderAppearance.benchmark used by the new compare route can pass through without a downcast. No behavior change on existing callers. ## Notes on items NOT shipped in this batch - Cache-Control on /llms.txt / /api/citable / /api/llm-context: the audit report showed `cache-control: public` and inferred no CDN caching, but the source already sets `public, s-maxage=300, stale-while-revalidate=900`. Vercel strips the `s-maxage` directive from the client-visible header while honoring it at the edge; `x-vercel-cache: HIT` with `age: 256` confirms the CDN cache is active. Not a real bug. - .bib / .ris file MIME endpoints: string versions already ship inside the /api/stat JSON via cite.bibtex / cite.apa. Marginal upside for Zotero-style one-click imports; downgraded to P2. - /api/stat/{slug}/sparkline.svg: sparkline data already ships as a float array in /api/stat and the OG image at /api/og/{slug} covers the "embed a chart in an answer" case. P2. - JSON-LD `Dataset.variableMeasured.value`: current schema emits bare strings; upgrading to PropertyValue objects would duplicate the measured value already carried by the StatisticalReport node's Observation.measuredValue. P2. * openapi: add totalWins to Compare schema --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * feat: Wave 1 P2 batch — cite MIME, sparkline SVG, Dataset PropertyValue (#1158) * feat: Wave 1 P2 batch — .bib/.ris/apa MIME, sparkline SVG, Dataset PropertyValue Three GEO surface additions the audit downgraded to P2. Each one is duplicative with an existing channel (cite.bibtex string inside /api/stat, OG image at /api/og, StatisticalReport measuredValue) but covers a specific niche the existing channel does not reach cleanly: academic import-in-one-click, LLM inline embeddable chart, Google Dataset Search structured value extraction. Ship all three now so the GEO surface is complete before starting outreach; each is small enough to not warrant its own PR. ## /api/cite/{slug}/{format} Per-bench citation with the correct MIME type. Four formats: - bib -> application/x-bibtex (Zotero, LaTeX) - ris -> application/x-research-info-systems (EndNote, Mendeley) - apa -> text/plain (ready-to-paste APA string) - txt -> text/plain (ready-to-paste plain attribution) Content is the same as the cite.* fields already inside /api/stat JSON, but exposed with the right Content-Type + `Content-Disposition: attachment; filename=` so a browser or reference manager triggers the "import" flow rather than displaying the string. Added RIS to CiteBundle in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2 / ER fields, CRLF line endings per RFC convention). ## /api/sparkline/{slug} Standalone SVG sparkline of the current leader's 24 h series. Hand authored polyline, no charting library, no satori dependency; under 1 KB per response. Query params: `w` (40..1200, default 240), `h` (20..400, default 60), `theme=light|dark`. Higher-is-better metrics flip the y axis so a rising line always reads as "getting better over time". Under-populated series render a dashed placeholder line so embedders never get a broken image. Cases this reaches that /api/og and the raw JSON sparkline array do not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to posts, Notion pages. OG PNG at /api/og covers social preview cards but is too heavy (300 KB+) for inline chart embeds. ## Dataset.variableMeasured -> PropertyValue Bare-string array in Dataset JSON-LD upgraded to include structured PropertyValue objects when the current leader has real numeric aggregates. Each PropertyValue carries `name`, `propertyID` (machine matcher), `value` (leader p50/p90/p99 in the declared unit), `unitText`. sample_size stays a bare string — it labels an axis, not a measurement. Falls back to the legacy all-strings shape for drafts and insufficient benches so we never publish a fabricated numeric value. New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts. BenchDatasetInput.variableMeasured now accepts Array<string | VariableMeasuredValue>. `keywords` extraction pulls the `.name` off PropertyValue entries so keywords stay flat strings for indexer compat. ## openapi.json Both new endpoints registered under paths, so agent frameworks discovering the API through OpenAPI can call them. * sparkline: XML-escape bench title + provider name in SVG --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * bridge-monitor: Mobula EIP-712 signature-confirm flow (fixes stuck deposits) (#1160) * bridge-monitor: implement Mobula EIP-712 signature-confirm flow (fix stuck deposits) * mobula sig: inject EIP712Domain type definition, API omits it --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081) * subsidy is a chain KPI, not a bench: kill standalone spec, add 3 cards to /chains/robinhood strip * pm-api-latency: add Predexon (bench 6th provider), fresh key deployed on the 3 Railway probe regions * usdy: 3-retry on Orca RPC to fix 40% success rate (#1168) publicnode.com burst-limits the VPS on getAccountInfo, dropping ~60% of samples. Two retries with 500ms backoff bring hit rate to >95% without switching endpoints. Also add commitment=confirmed to reduce slot-miss null returns. Co-authored-by: Florent Tapponnier <contact@mobula.io> * bench vague 2: ship 081-085 (ws latency, oracle freshness, rpc reliability, indexer latency, block builders) (#1170) * add bench 081 ws-head-latency: WS block-push lag harness + spec * add bench 084 indexer-latency: HyperSync vs The Graph vs Alchemy time-to-queryable * idx-latency: only classify error when every poll errored, else timeout * bump bench-set cache keys for 084 (lockstep rule) * add bench 082 oracle-freshness: staleness metrics in oracle-deviation harness (multi-chain chainlink, pyth publish_time fix, redstone), bump bench-set cache keys * oracle-freshness: count unsupported mainnet feeds in freshness error counter * add bench 085 evm-block-builders: ETH builder share via extraData + relay bidtraces + Arb/Base soft-conf lag, bump bench-set cache keys * ebb: don't count historical relay payloads on first poll, retry arb calibration on zero support, reset WS backoff after stable session * add bench 083 rpc-reliability: consensus lag, hash quorum, fixed-vector integrity in rpc-capabilities harness, bump bench-set cache keys * bench 083: clamp_min rank_matrix_query so zero-incident providers keep cell ranks * gate benches 081-085 behind staging until 48h clean data * drop shipped RWA slugs from prod gate (live on main since #1162/#1167) --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * hyperliquid frontend page: add ProviderLogo in the hero next to the name * hl frontends: add slash, topdog, markets-mobile logos + registry entries; dark-mode chip for slash + markets-mobile * idx-latency: swap Alchemy for Mobula in the cohort (paid-plan key, bench races $0 pipelines only) (#1176) Co-authored-by: Florent Tapponnier <contact@mobula.io> * fix(bench): sample-size PromQL + leader reliability floor (#1175) * fix(bench): sample-size PromQL + leader reliability floor Two bench-correctness fixes surfaced by an audit of the oracle and gas surfaces we plan to cite in outreach. Bench 025 (oracle-deviation): - sample_size was 'count(count by (source) (ocb_oracle_price))' which returns the count of distinct sources (3 or 4), not the count of samples over the window. The citable API therefore reported sampleSize=3-4 on every pair, at odds with the spec's 'polled every 30s' claim. Rewrites the query to 'sum(count_over_time(ocb_oracle_price[24h]))' across all 10 pair entries so the number reflects actual poll volume. Leader ranking: - citationCandidates() now excludes providers with a measured success rate below 50 percent from the leader pool. Before this fix, gas-estimation showed Owlracle as #1 with successRate=6.48 and a p50 gap of 0.001 gwei: technically accurate on the rare successful poll but useless as a leader claim. Falls back to the full live pool when every provider is under the floor so a totally degraded bench still surfaces a best-of-bad-options leader rather than vanishing. Freshness benches without a success query default to 100 percent in the loader so the guard is inert there. Adds src/lib/citation.test.ts with 5 tests covering the Owlracle scenario, higher-is-better ranking, and the degraded-pool fallback. * fix(bench): propagate reliability floor to rankings + template + hub Follow-up on the previous commit. Review flagged that filtering the leader without filtering the ranking arrays leaves self-contradictory JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle on the same document. Exports citationCandidates and adds rankedCandidates helper that returns the same eligible pool sorted by the bench's higher_is_better direction. leader() and fieldValue() now share it. Wires the shared helper into every surface that emits a rankings-like list or a best-of claim: - src/app/api/stat/[slug]: rankings array now sorted from rankedCandidates so rankings[0].slug always equals leader.slug on the same JSON. - src/app/api/llm-context: the Rankings numbered list under each bench matches the Headline sentence above it. - src/app/api/mcp/[transport]: get_benchmark tool response and the openchainbench://benchmark/{slug} resource both share the pool. - src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}} tokens in bench copy resolve against the same pool so a bench body never crowns a provider its own headline excludes. Per-slug tokens {{p50:some-slug}} still resolve against the raw live pool so unknown tokens still fall through. - src/lib/providers: rankProviders (drives /products hub wins count and per-chain leadership chips) now runs on the reliable pool. Adds a consistency test locking that rankedCandidates[0].slug always matches leader().slug on the same bench. Total: 6 tests in citation.test.ts, 95/95 pass across src/. Uses (r.successRate ?? 100) for the numeric guard to future-proof against ProviderResult shapes that skip the field. Load path still defaults to 100 (materialize/load.ts:748) so this is inert today, but the extra ?? removes the fragility. /api/compare/[a]/[b] is intentionally left unfiltered: the user picks the two providers explicitly, so the head-to-head is a requested comparison rather than a leader claim. Applying the floor there would drop rows the user explicitly asked to see. * fix(hub-card): honor citation leader in the hub grid headline Follow-up review flagged that benchmark-card.tsx re-sorted b.results locally and picked results[0], so a bench where citation demotes the raw-p50 winner (e.g. Owlracle on gas-estimation) would show the demoted provider on the hub grid while the bench page names the correct leader. Two documents contradicting each other in one click. Adds leaderSlug: string | null to BenchmarkCardData and populates it at the projection boundary via leader(b), which already applies the reliability + insufficient-sample filter. The card picks the leader by matching that slug in the projected results, and falls back to the raw best when the projection returned no leader (draft, insufficient, or all providers filtered out) so a card still renders a headline value in every state. Kept the local sort so the fallback path and any downstream chip using sorted[0].name keeps working. Test suite unchanged: 95/95 pass across src/. No test needed since this is a projection-boundary wire-up. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * fix(registry): remove duplicate topdog entry (#1177) Commit 349b786 (hl frontends: add slash, topdog, markets-mobile logos + registry entries) added a 'topdog' entry at line 313 without noticing a pre-existing 'topdog' entry at line 1061, breaking staging build with a duplicate object literal type error. Keeps the newer entry (Telegram bot at t.me/topdog_trade_bot, HL frontend context) since it was the one intentionally added for the Hyperliquid frontends leaderboard chip work. Drops the older Discord signal bot entry (topdog.gg): the bench spec at benchmarks/hyperliquid-frontends.yml has slug: topdog with name TopDog and matches the newer, HL-native product. Co-authored-by: Florent Tapponnier <contact@mobula.io> * vague2 QA: unit sec for true-seconds benches (fixes 0.0s heros), soften 080 findings, drop dead 085 builder rows, 083 expected_n 40 (#1178) Co-authored-by: Florent Tapponnier <contact@mobula.io> * add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster (#1179) Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns hex-encoded block number matching EVM's eth_getBlockByNumber shape), staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s cadence). Harness changes: - Chain.Kind gains "polkadot" alongside "solana" and default (EVM). - New callSubstrateHeader: chain_getHeader POST, parses hex block number, returns parentHash as the observed hash. Same latency measurement + result classification as the EVM path. - probeOne switches on Kind (was if solana / else). polkadot uses polkadotStaleBlockGap = 40. Consensus observation is skipped because chain_getHeader returns parentHash (N-1), not the current block hash (deriving current hash needs Blake2 over the SCALE- encoded header, out of scope). - Archive loop is skipped for polkadot the same way as solana: Polkadot state accesses via state_getStorage keyed by Blake2 storage keys, no chain-agnostic depth analog for eth_getBalance-by-depth. Providers at launch (3, live-verified on 2026-07-15 with four consecutive chain_getHeader probes each): - polkadot-official (Parity, rpc.polkadot.io) - onfinality (public gateway, Web3 Foundation grantee) - publicnode (Allnodes, extends its multi-chain footprint to Substrate keyless) Audited and excluded from the launch cohort: - 1RPC: filters chain_getHeader as "Not Allowed" on the privacy relay. Revisit if they expose a Substrate-friendly method set. - Dwellir: 503 Service Unavailable during the audit sweep, revisit once the gateway stabilises. - Ankr: paid Polkadot tier only. - Chainstack: key-gated on Substrate. - RadiumBlock: rejects without referral header. - Grove / Thirdweb: invalid-chain response. Spec (benchmarks/polkadot-rpc.yml, bench № 086): - Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent rpc-capabilities benchmark, chain cluster page and hub card render it identically. - Methodology documents the Substrate specifics: probe method choice, staleness scaling, why no archive-depth or hash-quorum columns. - SEO/finding tokens use the standard {{best_name}} / {{best_p50}} / {{p50:slug}} template surface, consistent with other -rpc benches. - Provider tag copy explains OnFinality and Dwellir's Web3 Foundation grantee status so the leaderboard reads correctly for someone who knows the Polkadot infra landscape. Local checks: - go build ./... clean. - go test ./... clean. - bun test src/ - 95/95 pass across 10 files, including the citation reliability floor and consistency tests added in #1175. Co-authored-by: Florent Tapponnier <contact@mobula.io> * product pages: 9 block-builder registry entries + verified logos, oracle logos (pyth/redstone) (#1181) * products: registry entries + logos for bench 085 block builders * oracle logos: redstone official logo + pyth/redstone manifest + registry entries --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * 085: add bobthebuilder provider row (real share, was metrics-only) (#1182) Co-authored-by: Florent Tapponnier <contact@mobula.io> * polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184) * add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns hex-encoded block number matching EVM's eth_getBlockByNumber shape), staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s cadence). Harness changes: - Chain.Kind gains "polkadot" alongside "solana" and default (EVM). - New callSubstrateHeader: chain_getHeader POST, parses hex block number, returns parentHash as the observed hash. Same latency measurement + result classification as the EVM path. - probeOne switches on Kind (was if solana / else). polkadot uses polkadotStaleBlockGap = 40. Consensus observation is skipped because chain_getHeader returns parentHash (N-1), not the current block hash (deriving current hash needs Blake2 over the SCALE- encoded header, out of scope). - Archive loop is skipped for polkadot the same way as solana: Polkadot state accesses via state_getStorage keyed by Blake2 storage keys, no chain-agnostic depth analog for eth_getBalance-by-depth. Providers at launch (3, live-verified on 2026-07-15 with four consecutive chain_getHeader probes each): - polkadot-official (Parity, rpc.polkadot.io) - onfinality (public gateway, Web3 Foundation grantee) - publicnode (Allnodes, extends its multi-chain footprint to Substrate keyless) Audited and excluded from the launch cohort: - 1RPC: filters chain_getHeader as "Not Allowed" on the privacy relay. Revisit if they expose a Substrate-friendly method set. - Dwellir: 503 Service Unavailable during the audit sweep, revisit once the gateway stabilises. - Ankr: paid Polkadot tier only. - Chainstack: key-gated on Substrate. - RadiumBlock: rejects without referral header. - Grove / Thirdweb: invalid-chain response. Spec (benchmarks/polkadot-rpc.yml, bench № 086): - Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent rpc-capabilities benchmark, chain cluster page and hub card render it identically. - Methodology documents the Substrate specifics: probe method choice, staleness scaling, why no archive-depth or hash-quorum columns. - SEO/finding tokens use the standard {{best_name}} / {{best_p50}} / {{p50:slug}} template surface, consistent with other -rpc benches. - Provider tag copy explains OnFinality and Dwellir's Web3 Foundation grantee status so the leaderboard reads correctly for someone who knows the Polkadot infra landscape. Local checks: - go build ./... clean. - go test ./... clean. - bun test src/ - 95/95 pass across 10 files, including the citation reliability floor and consistency tests added in #1175. * polkadot page + parity/onfinality/dwellir products Wires the /chains/polkadot hub page, provider profiles, and logo assets that surface once bench 086 polkadot-rpc starts feeding Prom. - chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT). Auto-populates /chains/polkadot once benches referencing chain="polkadot" land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables when the chain-kpis harness starts sourcing Polkadot alongside the other L1s. - provider-registry.ts: three new entries for the Polkadot cohort. * parity: brand entry for Parity Technologies (Polkadot / Substrate implementation shop, operator of rpc.polkadot.io). * polkadot-official: endpoint-level entry describing the reference rpc.polkadot.io keyless endpoint; providers.ts aliases the slug to parity so the /products page collapses under the operator's brand. * dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless Substrate infra) — excluded from the 086 launch cohort due to a 503 during the audit sweep but kept in the registry for the revisit and for other places their brand might appear. - providers.ts: add polkadot-official → parity alias and canonical brand casings for polkadot / parity / onfinality / dwellir so the /products hub, badges, and OG images render the correct name without the title-case fallback butchering them. - logo-manifest.ts: polkadot.png (chain hero), parity.png (product page logo), plus polkadot-official → parity slug alias so the endpoint chip on the bench page picks up Parity's mark automatically. 95/95 pass on bun test src/. Local build clean. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186) Co-authored-by: Florent Tapponnier <contact@mobula.io> * store: last-known-good snapshot fallback (no more awaiting after first publish) (#1188) publishSnapshot writes an lkg copy without TTL on every success; readMaterialized falls back to it when the pointer/blob pair misses (worker outage past blob TTL, DEL race, transient). dataAsOf keeps the freshness badge honest about age. LKG write is best-effort and never fails the publish. Co-authored-by: Florent Tapponnier <contact@mobula.io> * chain-kpis: add polkadot to registry (DOT native, stables via Asset Hub) (#1191) Ships live KPIs on /chains/polkadot. DOT price and mcap flow through Mobula. Stables card renders via DefiLlama Asset Hub aggregate. TVL and DEX gauges stay unpublished (relay chain has zero DeFi: parachain TVL lives under Acala, Moonbeam, Hydration). Also guards defillama TVL publish to skip zero values so chains that DefiLlama tracks by name but with an all zero series (Polkadot today) hide the TVL card instead of rendering $0. Co-authored-by: Florent Tapponnier <contact@mobula.io> * live-feed status: Feed down pill + banner on aggregator-head-lag (#1193) Short-window changes() probe per provider surfaces silent live feeds that the 24h quantile aggregate hides (Codex WS blackout 08:23-09:04 UTC today moved the p50 by nothing but the whole feed was mute for 40min). Bench-level probe_ok gate suppresses badges when our harness side is broken so a Railway crash cannot fake-flag every provider. Co-authored-by: Florent Tapponnier <contact@mobula.io> * series gaps: null the chart line when a feed goes silent (unless changes==0) (#1195) Companion to feed-down banner: makes the 40min Codex blackout from this morning (08:23-09:04 UTC) visible on the 24h chart instead of a flat line covering the outage. Per-series match preserves the aggregate line as long as one chain still receives events. Co-authored-by: Florent Tapponnier <contact@mobula.io> * add bench 087 ws-head-latency-base + 088 ws-head-latency-solana (#1196) Per-chain siblings of bench 081 for the WebSocket head-lag cluster. Same harness (harnesses/ws-head-latency), same race scoring, one spec per chain to avoid mixing block cadences (Ethereum 12s vs Base 2s vs Solana 400ms) in a single leaderboard. Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does not expose a keyless Base WebSocket path. Solana cohort: PublicNode + Solana Foundation. slotSubscribe replaces newHeads on the harness side; metric names are shared so the queries are structurally identical to the EVM specs. Data already flows via the Paris VPS deployment (chain label carries base and solana). No harness change needed for this ship. Multi-region is a follow-up on the parent bench. Co-authored-by: Florent Tapponnier <contact@mobula.io> * ws-head-latency: multi-region (harness region label + spec region tabs) (#1198) * ws-head-latency: region label via ConstLabels + Railway config Adds a static region ConstLabel to every ws-head-latency metric, bound at process startup from OCB_REGION (preferred) or RAILWAY_REPLICA_REGION (Railway auto-set), with eu-west fallback matching the historical single-vantage Paris VPS deployment. No observation site needs to change: the label is baked into every counter, gauge and histogram at registration, so all existing WithLabelValues(provider, chain) calls stay identical. Adds railway.toml so Railway can build the harness from Dockerfile with a /health check, matching the rpc-capabilities service layout. Follow-up: create the Railway ws-head-latency service with 3 replicas (us-east, eu-west, sgp) and OCB_REGION set on each, then decommission or leave the Paris VPS container as a 4th vantage. * ws-head-latency specs: add region dimension tabs (081 + 087 + 088) Adds `dimensions.region` block + `rank_matrix_query` on provider, region + per-provider `regions:` sub-arrays with region-scoped queries on the 3 ws-head-latency specs, following the polkadot-rpc pattern. Depends on the harness region label change (previous commit): every metric now carries a region ConstLabel so the region-scoped histogram queries return values as soon as multi-region replicas publish. Until Railway 3-replica deploy lands, only region=eu-west has data and the other tabs render empty; the All regions tab keeps working because the aggregate query is unscoped by region. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * ws-head-latency: align region env var with rpc-capabilities (REGION not OCB_REGION) (#1200) (#1201) Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet. Co-authored-by: Florent Tapponnier <contact@mobula.io> * chart: red band overlay for null-bucket downtime windows (#1203) Companion to #1195 (series null gaps). The gaps themselves render as line breaks but a break can read as 'the chart missed a sample'. A soft red column behind the break spans the exact outage window and makes the reader understand it was a real feed-side silence. Per line + per contiguous gap range, extended by one step so 1-bucket outages stay visible instead of collapsing to a zero-width sliver. Hidden for excluded (legend-toggled) providers. Co-authored-by: Florent Tapponnier <contact@mobula.io> * rename ws-head-latency -> ws-head-latency-ethereum for sibling slug parity (#1204) 081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve). Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive. Co-authored-by: Florent Tapponnier <contact@mobula.io> * network-coverage: drop coinstats (free tier credits exhausted) (#1206) CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned. Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API). Co-authored-by: Florent Tapponnier <contact@mobula.io> * Revert "network-coverage: drop coinstats (free tier credits exhausted) (#1206)" (#1208) This reverts commit d295f7168da981078164119e508ca9546ed69fa2. Co-authored-by: Florent Tapponnier <contact@mobula.io> * downtime bands: provider-colored tint + name/silent label + edge markers (#1210) Feedback on #1203: the neutral red band read as 'something happened here' without telling the reader WHO. Tint each band in the provider's own color, add dashed edges at the outage boundaries, and label 'PROVIDER · SILENT' above the band when it fits (with a caret that survives on narrow single-bucket bands). Works uniformly for Mobula/Codex/Gecko or any future provider with a live_activity query. Co-authored-by: Florent Tapponnier <contact@mobula.io> * downtime bands: pill labels + vertical stacking on overlap (#1211) Screenshot review: the small text-only label above the band was still too subtle to read as 'this provider is down', and 3 simultaneous provider outages produced overlapping unreadable labels stacked on the same y. Replace with high-contrast rounded pills (white text on provider color) that stack vertically when their X-ranges overlap. Renders as '● NAME DOWN' in bold small caps so a reader gets the event and the provider identity at a glance. Co-authored-by: Florent Tapponnier <contact@mobula.io> * tsp-arb-latency: lower trigger 50 -> 25 bps + credit sub-poll closures (#1212) Bench 080 published only 7 samples across 3 of 11 assets in 24h at 50 bps trigger. Two contributing causes fixed together: 1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on Nasdaq (earnings, macro prints), not a steady-state event. 25 bps is above normal intra-minute noise on liquid stocks and captures real arb-worthy moves without collapsing the semantic. 2) Sub-poll convergence credited. When the reference moves past the trigger AND the pool is already within band on the same tick, the arb closed inside one poll interval. Previously dropped silently; now observed at pollInterval (60s) so the fast-arb tail on liquid pools stops disappearing from the histogram. Convergence threshold stays at 20 bps: the "how close is the pool to fair" question is unchanged, only trigger sensitivity moves. Spec text (subtitle, methodology, FAQ) updated to 25 bps throughout. Co-authored-by: Florent Tapponnier <contact@mobula.io> * downtime pill: drop the leading dot indicator, center the label (#1213) Co-authored-by: Florent Tapponnier <contact@mobula.io> * downtime pill: 'DATA MISSING' wording + skip leading gaps (#1215) Two fixes based on 30D view review: - Leading gaps (Prom retention shorter than the visible window, harness started mid-range, provider added recently) rendered as a chart-wide band claiming every provider was down for the missing period. Skip runs that started before the first observed sample. - Rename 'DOWN' to 'DATA MISSING'. A silent series can be the provider going down, our harness losing its WebSocket, or a Prom scrape failure — the pill stays neutral instead of blaming the provider. Co-authored-by: Florent Tapponnier <contact@mobula.io> * drop bench 084 indexer-latency (harness pruned, spec deleted) (#1218) HyperSync and The Graph need paid keys for the sustained cadence the bench expects; Mobula alone left the leaderboard single-provider and the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to drop the whole cluster than to buy 2 SaaS subscriptions to prop up a bench nobody has cited yet. Removed: * benchmarks/indexer-latency.yml * harnesses/indexer-latency/ (whole directory) Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns Gone rather than 404. Cache keys bumped so cached v30/v33 entries that still enumerate the slug age out on next revalidate. VPS follow-up (separate ops): stop ocb-indexer-latency container, remove from docker-compose.yml + prometheus.yml scrape config. Co-authored-by: Florent Tapponnier <contact@mobula.io> * video/render: strict Zod body schema before upstream forward (#1216) Co-authored-by: Florent Tapponnier <contact@mobula.io> * mcp: allowlist perp_funding_*, perp_venue_*, perp_execution_* prefixes (#1220) Co-authored-by: Florent Tapponnier <contact@mobula.io> * aggregator-head-lag: drop 'unless changes==0' from series queries (#1221) Bug: the guard was designed for modern 15s-cadence data, where changes([15m]) works cleanly. On backfilled historical data compacted to ~1 sample/hour, changes([15m]) returns 0 even though the gauge is healthy — the 30D view lost the first 8 days of data to false-drops (all three providers marked DATA MISSING for a whole week). Real-time silence is still surfaced by the live_activity scalar (pill + banner + downtime band), which runs against fresh scrape cadence and stays accurate. The chart series now shows every point Prom has, flat during old outages instead of gapped. Co-authored-by: Florent Tapponnier <contact@mobula.io> * seo: 200 placeholders + sitemap threshold buffer (#1222) - badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data - hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder - compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race. Co-authored-by: Florent Tapponnier <contact@mobula.io> * aggregator-head-lag: restore outage detection with backfill-safe guard (#1224) Follow-up to #1221. Dropping the guard entirely restored the 30D range but silently hid today's Codex morning blackout on the 24H view (frozen gauge value = flat line, indistinguishable from a healthy quiet period). Bring the guard back, gated on count_over_time > 5 samples in the window. Modern data at 15s cadence carries ~60 samples per 15 min so the gate fires the moment a WS goes silent. Backfilled data compacted to ~1 sample/hour has count <= 2 in a 15m window so the gate stays open and every point Prom kept is served — no more fake wall-to-wall gap at the start of the 30D range. Co-authored-by: Florent Tapponnier <contact@mobula.io> * ws-head-latency: sub-ms histogram floor + keyed slots for base/solana (#1225) Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a [0,5ms] first bucket interpolates every winner's p50 to ~2.5ms (chain-independent). Replace with a 0.5ms floor climbing to 10s so sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s). Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode) and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can extend beyond keyless once contributor keys are wired. Keyless tier is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC, BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack- public — none accepted a keyless WSS handshake as of 2026-07). Co-authored-by: Florent Tapponnier <contact@mobula.io> * ship: ungate bench 085 evm-block-builders on prod (#1227) Titan leads with sample=6973 in 24h, data healthy since the harness ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod next revalidate. Cache keys bumped to bench-unfiltered-v32 + all-benchmarks-v35 so cached entries do not keep dropping the slug via the old prod gate. Co-authored-by: Florent Tapponnier <contact@mobula.io> * loader: propagate 24h series null-runs to 7d/30d (#1229) Fine-grained outages (24h step = 20 min buckets) rarely align with coarser evaluation timestamps (7d step = 2h, 30d step = 12h) so the Prom query returns a healthy average and the reader sees the pill on 24H but nothing on 7D/30D. Post-process: for every contiguous null run detected on s24, translate the time-ago range to bucket indices on s7 and s30 and null every coarse bucket the run straddles. Contiguity preserved so a single short outage doesn't split into two adjacent pills after propagation. Same treatment applied to per-region series. Co-authored-by: Florent Tapponnier <contact@mobula.io> * propagateNullsToCoarser: use absolute time-ago instead of window fraction (#1230) Bug: the previous math scaled by the fine window fraction, which projected a 7h-ago outage down to 40h-ago on the 7d grid (the pill landed at -1.7d instead of -6h). Both grids share 'now' as their right anchor and only the step sizes differ — take the fine idx's absolute time-ago and divide by the coarse step to find the covering bucket. Co-authored-by: Florent Tapponnier <contact@mobula.io> * tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL AMZN MSFT SPY) (#1231) Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU. Co-authored-by: Florent Tapponnier <contact@mobula.io> * perp-fees harness: fix Paradex depth-skip, gains SOL slot, dYdX walk order, GMX Subsquid error surfacing (#1233) - Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values. - gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL. - dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers. - gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path. Verified: go build + go vet clean. Co-authored-by: Florent Tapponnier <contact@mobula.io> * downtime bands: opt-in via live_activity, skip on other benches (#1235) Screenshot review: rpc-reliability rendered a 'LAVANODIES DATA MISSING' pill even though its spec never declared live_activity — natural nulls in its series24h (sparse scrapes, backfill edges) triggered the DowntimeBands render. Gate the render on benchmark.results.some(r => r.liveStatus != null), which is only set when the spec declared live_activity. Same gate on propagateNullsToCoarser so other benches' 7d/30d series stay untouched. Co-authored-by: Florent Tapponnier <contact@mobula.io> * batch 2: MEV chips + rpc copy + Aster/edgeX + Cardano/Sui/Cosmos/Avax (#1236) * site: provider_notes chip on ranking rows + rpc-capabilities copy fixes - validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat - New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render) - Chip renders only when the note is set, no visual change to any other bench - rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ - rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST) * perp-fees harness: add Aster + edgeX venues - Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API) - edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0) - Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers - Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume) * validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains. - Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io. - Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs. - Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission). - Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100). Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * rpc-capabilities: add 4 keyless providers (ThirdWeb Blast Gateway.fm bloXroute) (#1238) Broadens the keyless cohort for benches 010 (rpc-capabilities), 083 (rpc-reliability) and the per-chain RPC benches. All 4 providers verified live returning valid eth_getBlockByNumber on their respective chains before inclusion. New (provider, chain) cells: ThirdWeb 8 chains (ETH POL ARB OP BASE BSC AVAX LINEA) Blast API 4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier bloXroute 3 chains (ETH BASE BSC) Gateway.fm 1 chain (ETH) Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix groups by provider+chain) and the per-chain leaderboards after the Railway rebuild picks up main. Co-authored-by: Florent Tapponnier <contact@mobula.io> * ws-head-latency: add Onfinality Ethereum to keyless cohort (#1241) Co-authored-by: Florent Tapponnier <contact@mobula.io> * drop oracle-freshness (082) spec + sync REMOVED with main state (#1245) Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal. Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader). Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state. Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37. Co-authored-by: Florent Tapponnier <contact@mobula.io> * SEO/E-E-A-T pack: /team page, Person schema, 23 positioning, 8 expert_take (#1249) Adds a broad SEO and GEO upgrade landing three surfaces at once: - /team page with Florent Tapponnier as Maintainer, sameAs GitHub + LinkedIn, Person JSON-LD wired into Organization.founder and into every bench StatisticalReport.contributor + TechArticle.author + reviewedBy so YMYL crypto queries see a named human accountable. - 23 positioning fields on alternatives specs (200 words each, factual, sourced from official docs, fact-checked via WebFetch). Cuts the shared-template surface from ~65 percent to ~30 percent across pages that reference the same benchmark. - 8 expert_take fields on answers specs (150-200 words each, structural commentary that stays valid regardless of who currently leads the leaderboard) with QAPage schema so the acceptedAnswer (Org, auto) and suggestedAnswer (Person, expert_take) both reach Google + AI Overviews. - Byline on answers pages when expert_take is present, linking to /team, hidden when dataPending so editorial context never renders next to an empty leaderboard. - noindex on awaiting benches so Prom outages do not surface thin placeholder pages to crawlers. - Optional Zod fields positioning + expert_take + expert_take_reviewed with noAiDashes refine so future drafts stay in policy. - Sitemap prebuild script emits real git-commit timestamps for the 8 editorial pages so lastmod stops resetting to build time on every unrelated Vercel deploy. - scripts/editorial-check.sh lints the two new fields for em/en dashes, marketing adjectives, pronouns, year references. Co-authored-by: Florent Tapponnier <contact@mobula.io> * alternatives: render positioning + clean paragraph line-wrap (#1250) Two fixes on top of #1249: 1. Add a Positioning block on /alternatives/[slug]/page.tsx that renders the newly-added field. Phase 1 added the Zod schema field but never wired the UI, so the content sat in YAMLs invisible on staging. 2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a single line per paragraph. Previous version hard-wrapped at 180 chars, which YAML literal (|) preserves as newlines, splitting words mid-token in the rendered HTML. Co-authored-by: Florent Tapponnier <contact@mobula.io> * hotfix: strip glued expert_take_reviewed from status line (#1251) * alternatives: render positioning + clean paragraph line-wrap Two fixes on top of #1249: 1. Add a Positioning block on /alternatives/[slug]/page.tsx that renders the newly-added field. Phase 1 added the Zod schema field but never wired the UI, so the content sat in YAMLs invisible on staging. 2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a single line per paragraph. Previous version hard-wrapped at 180 chars, which YAML literal (|) preserves as newlines, splitting words mid-token in the rendered HTML. * fix: strip glued expert_take_reviewed from status line The previous rewrite regex dropped the newline before existing expert_take_reviewed values without adding a proper break, so 'status: live' and 'expert_take_reviewed: ...' ended up on the same line, failing YAML parse and killing the build export step. Also collapsed duplicate expert_take_reviewed lines that landed during the double-run of the rewrite script. --------- Co-authored-by: Florent Tapponnier <contact@mobula.io> * hotfix: strip glued status line in 8 answer YAMLs (#1252) Fix that landed in a prior branch was lost during a squash-merge sequence. Reapplying directly off dev to guarantee it lands. Co-authored-by: Florent Tapponnier <contact@mobula.io> * answers: drop visible byline on expert_take blocks (#1253) The visible "By Florent Tapponnier, reviewed <date>" byline implied authorship of every expert_take paragraph and dated the review with a fabricated timestamp, both editorially fragile. Keep the E-E-A-T signal at the schema layer only: Person JSON-LD on Organization.founder, StatisticalReport.contributor, TechArticle author + reviewedBy, and QAPage suggestedAnswer.author. Search engines see the human attribution where it counts; readers see the editorial commentary without a personal claim on every crypto opinion the site publishes. Removes the unused Byline component too. Co-authored-by: Florent Tapponnier <contact@mobula.io> * drop bench 080 tokenized-stock-arb-latency (Rob Chain pools too illiquid) (#1254) 5 of 11 tickers had frozen pool prices (no swap activity). Remaining 6 had p99 pinned at the 32min histogram cap because arbs rarely close within the settle window. Robinhood Chain launched Mar 2026, tokenized equity market still too young for a meaningful arb-latency bench. Removed: * benchmarks/tokenized-stock-arb-latency.yml * harnesses/tokenized-stock-peg/cmd/script/arb_latency.go * arb_tracker calls + closeStaleOnStateChange in main.go Kept: bench 077 tokenized-stock-peg + 079 weekend-drift (static peg measurements) stay live on prod as the honest signal for this cluster. Slug in REMOVED_BENCH_SLUGS for 410 Gone. Cache keys bumped. Co-authored-by: Florent Tapponnier <contact@mobula.io> * jsonld: add 'name' to TechArticle nodes for Google Rich Results validation (#1257) GSC flagged 'Missing field name' on aggregator-head-lag JSON-LD (and by extension every bench slug page + every per-chain page + the methodology hub — all TechArticle nodes shipped only 'headline'). Schema.org Article inherits 'name' from CreativeWork; Google's Rich Results validator treats it as required even when 'headline' is present. Add 'name' alongside 'headline' with the same value on all three affected builders. Co-authored-by: Florent Tapponnier <contact@mobula.io> * share-card: propagate region/kind/venue filters + compose dynamic title (#1260) Feedback from Maxime: exported images do not adapt to the active filters on the bench page. A share-card exported from /benchmarks/rpc-capabilities?chain=ethereum®ion=sgp used to render just 'Fastest free public RPC ...' with cross-chain data; now the loader picks up the region filter and the title reads 'Fastest free public RPC ... Ethereum · Singapore' with the correctly scoped data. Change: * share-card route reads region, kind, venue URL params (symmetric with the existing chain param). Each maps to a spec dimension and forwards to the materialize loader so the data is filtered too, not just the title. * A composed contextParts string is built from the resolved dimension labels and appended to the aggregate title. Empty when no filter is active, no trailing separator on the default view. * The composed title is overlaid onto the benchmark passed to each render (spread with {...b, title: displayTitle}) so all 5 templates (ranking, leaderboard, snapshot, headline, compare) pick it up without any render-signature changes. * share-section + share-section-modal read live URL params for region/kind/venue in addition to chain when composing the card URL, so a modal opened from a scoped bench page passes the same scope to the exported PNG. Follow-up (not in this PR): view / range / topN selectors, per-bench UI in the modal to expose the dimension pickers explicitly. This PR is the plumbing; the modal UX for exposing new pickers is next. Co-authored-by: Florent Tapponnier <contact@mobula.io> * Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261) This reverts commit b0ce3ef564fe4ca2aca905245c0d4dd1c6373054. Co-authored-by: Florent Tapponnier <contact@mobula.io> * Revert "Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)" (#1263) This reverts commit d7f0fcbbbb6ad0db967c285fd23f99ca76b066f5. Co-authored-by: Florent Tapponnier <contact@mobula.io> * aggregator-head-lag: peer-activity clause (on/chain,region) fixes pool-silent false positives (#1265) Co-authored-by: Florent Tapponnier <contact@mobula.io> * share-card: filter negative p50 in sortByP50 (fixes HTTP 500 on RPC benches) (#1266) Commit c03c599 relaxed the sortByP50 filter from `p50 > 0` to `p50 !== 0` to support the negative-p50 'warming up' sentinel introduced for funding benches on the /products page. The relaxation leaked into the share-card render path, where negatives crash Satori: negative p50 values fed to Math.max produce a negative maxP50, which inverts every bar height ratio and blows up the JSX with an opaque 'Spread syntax' error mid-render. Symptoms observed: /benchmarks/{rpc-capabilities,polkadot-rpc,ws-head-latency-ethereum,evm-block-builders}/share-card?template=ranking all returning HTTP 500. Working benches (network-coverage, perp-fees, oracle-deviation) had no negative sentinel values in their data. Fix restores `p50 > 0` in the share-card filter. The negative sentinel handling stays on the /products page where it was designed to render (as 'warming up'). Share-card treats negatives as noise and drops them, same as zeros. Co-authored-by: Florent Tapponnier <contact@mobula.io> * share-modal: explicit dimension pickers (chain / region / kind / venue) (#1267) Feedback from Maxime: user needs to be able to pick chain/region/kind/venue from within the share modal, not just inherit whatever the URL had when opening. Also the composed card title must reflect the pick in real-time. Adds a DimensionRow chip component rendered above the template tabs. One row per dimension the bench declares (chainDims / regionDims / kindDims / venueDims). Each row: 'Label' + 'All' chip + one chip per option. Clicking a chip toggles state; clicking the active pick clears back to All. State is seeded from the live URL on mount so the modal opened from a scoped bench page starts on the same scope (unchanged behaviour). User can override in-modal by clicking a different chip; state updates re-render the card preview with the new filter forwarded to the loader. The cardSrc URL builder now reads from picker state instead of the live URL - previous implementation re-read the URL every render which meant the modal's own state changes couldn't propagate. Same URL param names on the share-card route: no server-side change needed. Bench without dimensions: block hidden entirely, modal looks unchanged. Co-authored-by: Florent Tapponnier <contact@mobula.io> * aggregator-head-lag: ws_connected guard filters out harness-reconnect false positives (#1269) Co-authored-by: Florent Tapponnier <contact@mobula.io> * hotfix(share-card): revert region/kind/venue passthrough + title overlay (#1270) The region/kind/venue param plumbing (added via #1260 → reverted via #1261 → re-added via #1263) and the display-title overlay via {...b, title: displayTitle} still cause HTTP 500 on rpc-capabilities share-card and other benches with dimensions. My sortByP50 fix (#1266) turned out not to be the actual root cause; the runtime error persists after that fix + the modal picker PR (#1267). Reverts the route.tsx changes only: - Restore original getBenchmark({chain}) call (no region/kind/venue forwarding) - Restore original switch that passes plain benchmark/filteredSafe to renders (no {...b, title: displayTitle} spread) - chainLabel still passed through (unchanged from pre-Maxime-feedback) Keeps the modal DimensionRow pickers (#1267) intact for a future retry once the true root cause is found. They currently look for matching URL params but the server-side won't act on them until the route.tsx re-adds forwarding safely. Bug still open on rpc-capabilities/polkadot-rpc/ws-head-latency-*/ evm-block-builders share-cards, but now user-visible functionality (chain filter, existing templates on non-broken benches) is fully restored to pre-Maxime-feedback behaviour. Co-authored-by: Florent Tapponnier <contact@mobula.io> * aggregator-head-lag: strip ws_connected clause from geckoterminal (REST-only, no ws metric) (#1272) Co-authored-by: Florent Tapponnier <contact@mobula.io> * share-card: fix HTTP 500 on RPC benches (Satori AVIF/WebP + CSS var crashes) (#1273) Two independent bugs causing pre-existing HTTP 500 on rpc-capabilities, polkadot-rpc, ws-head-latency-*, evm-block-builders share-card endpoints. Reproduced locally with bun dev + shimmed try/catch to unmask the mid-stream Satori errors. Bug 1: Satori (the JSX-to-SVG renderer next/og uses) only decodes PNG / JPEG / SVG. Feed it AVIF or WebP bytes and it throws 'TypeError: u2 is not iterable' mid-render, surfacing as opaque HTTP 500. publicnode.avif, drpc.webp, lava.webp all trigger this on the RPC benches. Fix: skip AVIF/WebP extensions in getProviderLogoDataUrl, fall back to the initials chip. Bug 2: chipBackground / chipTextColor from src/lib/brand.ts return CSS var references (e.g. 'var(--color-ink-soft)') when the provider has no registered brand color. Satori evaluates var(...) to the CSS-wide 'initial' keyword and rejects 'background: initial', crashing the whole PNG. evm-block-builders is a full-cohort case (Titan, BuilderNet, Quasar, Eureka, Builder+, Vanilla — none have brand colors or logos). Fix: resolve var(..…
Flotapponnier
added a commit
that referenced
this pull request
Jul 26, 2026
prom.scalar takes result[0] from multi-series vectors. Prometheus returns series alphabetically so chain=base always comes first — the unfiltered view silently showed Base data for every provider and gave Codex (Base leader at 0.8 s) a false global #1 even though Mobula leads on BNB, Solana and Robinhood. Fix: drop the All chains dimension entry so the bench always shows per-chain data. Base is now the default tab (first in list). Also update seo_description, findings and faq to reference per-chain leaders instead of a misleading cross-chain aggregate.
Flotapponnier
added a commit
that referenced
this pull request
Jul 26, 2026
prom.scalar takes result[0] from multi-series vectors. Prometheus returns series alphabetically so chain=base always comes first — the unfiltered view silently showed Base data for every provider and gave Codex (Base leader at 0.8 s) a false global #1 even though Mobula leads on BNB, Solana and Robinhood. Fix: drop the All chains dimension entry so the bench always shows per-chain data. Base is now the default tab (first in list). Also update seo_description, findings and faq to reference per-chain leaders instead of a misleading cross-chain aggregate.
This was referenced Jul 26, 2026
Flotapponnier
added a commit
that referenced
this pull request
Jul 29, 2026
* feat(data-api): add chain sub-scores to By Provider pivot cells * fix(pm-ws-latency): remove kalshi from rank_matrix (delta-only WS != snapshot latency) * fix(data-api): chain chips show #1 rank + add missing chain labels
This was referenced Jul 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Header tagline removed, hero copy more product-y, section labels updated.